jackherd 1.3.0 → 1.5.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 +11 -0
- package/README.md +107 -37
- package/dist/{chunk-UI3AZNZF.js → chunk-HQAONP4W.js} +2 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +23 -1
- package/dist/plan-format.d.ts +2 -0
- package/dist/plan-format.js +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/ask-hint/decision.ts +28 -0
- package/kit/hooks/hooks/ask-hint/index.ts +44 -0
- package/kit/hooks/hooks/ask-hint/settings.ts +21 -0
- package/kit/hooks/hooks/ask-hint/transcript.ts +55 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +70 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +2 -1
- package/kit/hooks/hooks/merge-guard/gh.ts +12 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +27 -10
- package/kit/hooks/hooks/merge-guard/run.ts +22 -8
- package/kit/hooks/hooks/merge-guard/settings.ts +20 -4
- package/kit/hooks/hooks.json +8 -0
- package/kit/workflow/skills/follow/SKILL.md +10 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +17 -6
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +26 -0
- package/src/config/schema.ts +35 -0
- package/src/plans/lane-standing.ts +59 -0
- package/src/plans/layout.ts +32 -12
- package/src/plans/texts.ts +5 -1
- package/src/sync/render.ts +6 -1
- package/src/vocabulary/index.ts +4 -0
- package/src/watch/command.ts +20 -1
- package/src/watch/heartbeat-progress.ts +47 -0
- package/src/watch/source-command.ts +16 -0
- package/src/watch/source-run.ts +8 -1
- package/src/watch/stream-output.ts +20 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
What changed for people who use jackherd, newest first.
|
|
4
4
|
|
|
5
|
+
## 1.5.0
|
|
6
|
+
|
|
7
|
+
- **The plans view lists only the lanes that still have something to do.** Before, every lane without an agent was listed (56 of 61 on one board, nearly all of them finished). Now a lane marked cleaned up, and a lane without a worktree whose named phases are all ticked, are not shown. A lane without a worktree that still has an open phase, or no plan and a "starts after" other than "now", is listed under `· waiting` with what it waits for. A worktree without an agent stays under `· no agent`. The board needs no change. The watcher is unchanged.
|
|
8
|
+
- **Documents merge without waiting for checks.** The new setting `merge.withoutChecks` (default empty) lists path patterns such as `docs/**/*.md`. When every file of a pull request matches, `/orchestrate` merges at once instead of `gh pr checks --watch`, and the merge guard does not ask for a plain `gh pr merge <number>`: it checks the file list on GitHub itself and asks as before on any doubt. Pattern rules are in the README.
|
|
9
|
+
- **The heartbeat shows progress.** In a stream, a `working` lane whose phase count moved since the last heartbeat becomes `<lane> working: P12 7/12`; a lane that did not move adds nothing. The heartbeat line stays short.
|
|
10
|
+
- **Source scripts are TypeScript.** The `follow` skill now writes a source's script as `scripts/sources/<name>.ts`, started with `["bun", "scripts/sources/<name>.ts"]` (Bun must be installed). The token goes from `process.env` into the request header, and a failure is an exit code, not an empty list. The README recipes for Jira with `acli`, Linear and Sentry are replaced, and a test compiles each one. The watcher reads `bun` as a source's program to mean the Bun that runs it, so Bun need not be on the `PATH`. Existing `watch.sources` entries keep working; recipes without a script stay direct commands.
|
|
11
|
+
|
|
12
|
+
## 1.4.0
|
|
13
|
+
|
|
14
|
+
- **A question in running text gets a hint.** The new `Stop` hook `ask-hint` tells you when the last message of a turn ends in `?` and no `AskUserQuestion` dialog was used. It only hints; the turn ends as before. A lane session stays silent, because a lane asks its coordinator as a text block. Switch it off with `"hints": { "askUserQuestion": false }` in `.jackherd/config.json`. `jh sync` brings the hook to a project install; a global install ships none.
|
|
15
|
+
|
|
5
16
|
## 1.3.0
|
|
6
17
|
|
|
7
18
|
- **A merge or a release asks first.** The new hook `merge-guard` asks you before `gh pr merge` and `gh workflow run` run, behind options before the verb (`gh -R x pr merge`), a chain, a wrapper, `sh -c` and `eval`, also in auto mode. The commands are `guard.commands` in `.jackherd/config.json` (default these two, `git push` not among them); an empty list switches the hook off. It prints nothing unless it asks. It guards against a mistake, not against an agent that sets out to get around it; the README says where the line is.
|
package/README.md
CHANGED
|
@@ -170,7 +170,7 @@ When `init` creates a new config, it asks:
|
|
|
170
170
|
| Use it when | the team shares the kit through the repository | the repository must stay untouched, not even untracked files |
|
|
171
171
|
| Lives in | the project's `.claude/` | `~/.claude/` |
|
|
172
172
|
| Skill names | `/plan`, `/implement`, `/orchestrate`, … | `/jh-plan`, `/jh-implement`, `/jh-orchestrate`, … |
|
|
173
|
-
| Hooks | `git-guard`, `watch-guard`, `merge-guard`, `format-on-edit` | none; `init --global` offers only a `SessionStart` update notice |
|
|
173
|
+
| Hooks | `git-guard`, `watch-guard`, `merge-guard`, `ask-hint`, `format-on-edit` | none; `init --global` offers only a `SessionStart` update notice |
|
|
174
174
|
| Config | `.jackherd/config.json` | `~/.jackherd/config.json`, used where a repository has none |
|
|
175
175
|
| Update | `jh sync` | `jh update` |
|
|
176
176
|
|
|
@@ -531,6 +531,16 @@ A merge or a release is the step nobody takes back. In a project install the `me
|
|
|
531
531
|
- `guard.selfMerge.offer` (default `true`) lets `/orchestrate` offer "approve, and merge it yourself after green checks" in its PR question. With `false` the question has two options and the hook honours no such answer.
|
|
532
532
|
- `guard.selfMerge.approvalMinutes` (default 180) is how long that answer lets `gh pr merge <number>` pass without a second dialog.
|
|
533
533
|
|
|
534
|
+
**Merging without checks.** A pull request that only changes documents rarely needs its checks. List the paths in `merge.withoutChecks` (default empty, so every merge waits for the checks as before):
|
|
535
|
+
|
|
536
|
+
```json
|
|
537
|
+
{
|
|
538
|
+
"merge": { "withoutChecks": ["docs/**/*.md"] }
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
A pattern is made of path words with `*` and `**`: no `..`, not absolute, no braces or negation, and at least one whole path segment without a wildcard, so `docs/**/*.md` is fine and `**`, `**/*.md` and `**/*.*` are refused. Keep the folder in the pattern: a `.md` file elsewhere can be an agent instruction (`CLAUDE.md`, a skill) that ships with a package and deserves its checks. With the list set, `/orchestrate` lists a pull request's files with `gh pr diff <number> --name-only` before it waits for checks and merges at once when every file matches. The merge guard checks the same itself: for a plain `gh pr merge <number>` it asks GitHub for the pull request and then for its files in that repository (`gh api …/pulls/<number>/files`, which also names the old path of a renamed file) and does not ask you when the pull request is open, from this repository, GitHub lists as many files as the pull request changes (fewer than 3000) and every one matches a pattern. This works without a self-merge answer, also when `guard.selfMerge.offer` is `false`. No file, one file outside, a count that differs, a failed `gh` call or a pattern list it cannot read means it asks as before. Limit: a push between its look and the merge is not seen.
|
|
543
|
+
|
|
534
544
|
**What self-merge does.** You pick the second option of the PR question. The coordinator commits, pushes and opens the PR, waits for the checks in the background, merges with `gh pr merge <number> --squash --delete-branch` when they are green and tells you in one line; on a red check it does not merge and reports. The project supplement can block the option per approval tier.
|
|
535
545
|
|
|
536
546
|
**What the hook checks.** It does not take the agent's word for your answer. It reads the session's transcript, which Claude Code writes, and looks for a dialog result where you picked that exact option for a question naming one branch. Then it asks GitHub which branch pull request `<number>` carries and lets the merge pass if the two match, the PR is open and not from a fork, and the command is exactly the plain form: `gh pr merge <number>` with `--squash`, `--merge`, `--rebase` and `--delete-branch` as the only flags, run on its own in the directory you work in (no `-R`, no `--admin`, no chain, no wrapper). `gh workflow run` and every other guarded command ask every time.
|
|
@@ -580,7 +590,7 @@ coordinator coordinator no value (no agent named coordinator)
|
|
|
580
590
|
gh unavailable since 11:24:31
|
|
581
591
|
```
|
|
582
592
|
|
|
583
|
-
As a stream (`--stream`) it keeps running, and the heartbeat is one line:
|
|
593
|
+
As a stream (`--stream`) it keeps running, and the heartbeat is one line. A working lane shows its plan progress, `<lane> working: P12 7/12` (the lane's current phase and its ticked checkboxes, counted as the plans view counts them), but only when the count moved since the last heartbeat; a lane that stays on one number says nothing, and you look for yourself. The plans are read only when a lane works:
|
|
584
594
|
|
|
585
595
|
```text
|
|
586
596
|
armed: search=no agent board docs/plans/board-search/board.md
|
|
@@ -710,36 +720,70 @@ Three limits stand: the watcher cannot make a command read only, so name only co
|
|
|
710
720
|
|
|
711
721
|
##### Recipes
|
|
712
722
|
|
|
713
|
-
Each recipe is one `watch.sources` entry; put in your key, project or query, then try it with `--dry-run` (and `--raw` where the shape differs from what is shown). 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 script
|
|
714
|
-
|
|
715
|
-
**Jira with the Atlassian CLI.** Login: `acli jira auth login`, kept by `acli`. `acli jira workitem search` cannot return `updated` or `comment`, so a script searches for the keys and reads each one with `view`, a few at a time to stay well under the 30 seconds a source may run.
|
|
723
|
+
Each recipe is one `watch.sources` entry; put in your key, project or query, then try it with `--dry-run` (and `--raw` where the shape differs from what is shown). 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. 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. The closing `export {}` makes the file a module, so the type check accepts top-level `await` and the names do not clash between scripts. A recipe that needs no script (`gh …`, `curl` on a public page, `curl --netrc`) stays a direct command. The watcher starts a source without a shell and reads `bun` as the first word 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).
|
|
724
|
+
|
|
725
|
+
**Jira with the Atlassian CLI.** Login: `acli jira auth login`, kept by `acli`. `acli jira workitem search` cannot return `updated` or `comment`, so a script searches for the keys and reads each one with `view`, a few at a time to stay well under the 30 seconds a source may run. `plainText` joins the text and mention nodes of the comment's document tree into plain text; it is a pure function, so you can test it on its own. The script, for example `scripts/sources/jira.ts`, with your project in the query and `<host>` your `<site>.atlassian.net`:
|
|
726
|
+
|
|
727
|
+
```ts
|
|
728
|
+
// scripts/sources/jira.ts
|
|
729
|
+
const jql = 'project = PROJ AND assignee = currentUser() AND statusCategory != Done'
|
|
730
|
+
const host = '<host>'
|
|
731
|
+
const parallel = 6
|
|
732
|
+
|
|
733
|
+
type DocumentNode = { type?: string; text?: string; attrs?: { text?: string }; content?: DocumentNode[] }
|
|
734
|
+
type IssueComment = { author?: { displayName?: string }; body?: DocumentNode }
|
|
735
|
+
type Issue = { key: string; fields: { updated: string; summary: string; comment?: { comments: IssueComment[] } } }
|
|
736
|
+
|
|
737
|
+
const plainText = (node: DocumentNode): string =>
|
|
738
|
+
node.type === 'text' || node.type === 'mention'
|
|
739
|
+
? (node.text ?? node.attrs?.text ?? '')
|
|
740
|
+
: (node.content ?? []).map(plainText).join('')
|
|
741
|
+
|
|
742
|
+
const acli = async (args: string[]): Promise<unknown> => {
|
|
743
|
+
const child = Bun.spawn(['acli', ...args], { stdout: 'pipe', stderr: 'ignore', signal: AbortSignal.timeout(20_000) })
|
|
744
|
+
const output = await new Response(child.stdout).text()
|
|
745
|
+
if ((await child.exited) !== 0) process.exit(1)
|
|
746
|
+
try {
|
|
747
|
+
return JSON.parse(output)
|
|
748
|
+
} catch {
|
|
749
|
+
return process.exit(1)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
716
752
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
jql='project = PROJ AND assignee = currentUser() AND statusCategory != Done'
|
|
721
|
-
acli jira workitem search --jql "$jql" --limit 100 --json |
|
|
722
|
-
jq -r '.[].key' |
|
|
723
|
-
xargs -r -P 6 -I{} acli jira workitem view {} --fields summary,updated,comment --json |
|
|
724
|
-
jq -s 'map({
|
|
753
|
+
const entryOf = ({ key, fields }: Issue) => {
|
|
754
|
+
const latest = fields.comment?.comments.at(-1)
|
|
755
|
+
return {
|
|
725
756
|
key,
|
|
726
|
-
url:
|
|
727
|
-
updated:
|
|
728
|
-
summary:
|
|
729
|
-
author:
|
|
730
|
-
body: (.
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
757
|
+
url: `https://${host}/browse/${key}`,
|
|
758
|
+
updated: fields.updated,
|
|
759
|
+
summary: fields.summary,
|
|
760
|
+
author: latest?.author?.displayName ?? null,
|
|
761
|
+
body: latest?.body ? plainText(latest.body) : null,
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const keys = ((await acli(['jira', 'workitem', 'search', '--jql', jql, '--limit', '100', '--json'])) as { key: string }[]).map(
|
|
766
|
+
({ key }) => key,
|
|
767
|
+
)
|
|
768
|
+
const entries = []
|
|
769
|
+
for (let start = 0; start < keys.length; start += parallel) {
|
|
770
|
+
const batch = keys.slice(start, start + parallel)
|
|
771
|
+
const issues = await Promise.all(
|
|
772
|
+
batch.map((key) => acli(['jira', 'workitem', 'view', key, '--fields', 'summary,updated,comment', '--json'])),
|
|
773
|
+
)
|
|
774
|
+
entries.push(...(issues as Issue[]).map(entryOf))
|
|
775
|
+
}
|
|
776
|
+
console.log(JSON.stringify(entries))
|
|
777
|
+
|
|
778
|
+
export {}
|
|
735
779
|
```
|
|
736
780
|
|
|
737
|
-
|
|
781
|
+
A failed `acli` call ends the script with exit code 1, which the watcher reports as a failing source; nothing is printed that it could take for an empty list.
|
|
738
782
|
|
|
739
783
|
```json
|
|
740
784
|
{
|
|
741
785
|
"name": "tickets-decisions",
|
|
742
|
-
"command": ["
|
|
786
|
+
"command": ["bun", "scripts/sources/jira.ts"],
|
|
743
787
|
"map": {
|
|
744
788
|
"id": ".key",
|
|
745
789
|
"updatedAt": ".updated",
|
|
@@ -768,19 +812,34 @@ acli jira workitem search --jql "$jql" --limit 100 --json |
|
|
|
768
812
|
}
|
|
769
813
|
```
|
|
770
814
|
|
|
771
|
-
**Linear over GraphQL.** Token: a personal API key in `LINEAR_API_KEY`, sent as `Authorization: <key>` without `Bearer`. The script, for example `scripts/sources/linear.
|
|
815
|
+
**Linear over GraphQL.** Token: a personal API key in `LINEAR_API_KEY`, sent as `Authorization: <key>` without `Bearer`. The script, for example `scripts/sources/linear.ts`, with `<host>` the API host `api.linear.app`. Linear answers a bad key or query with HTTP 200 and an `errors` list, so the script checks for it as well:
|
|
772
816
|
|
|
773
|
-
```
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
817
|
+
```ts
|
|
818
|
+
// scripts/sources/linear.ts
|
|
819
|
+
const token = process.env.LINEAR_API_KEY
|
|
820
|
+
if (!token) process.exit(1)
|
|
821
|
+
|
|
822
|
+
const query = `{ issues(filter: { team: { key: { eq: "ENG" } }, number: { eq: 123 } }) {
|
|
823
|
+
nodes { identifier title url updatedAt comments { nodes { body user { name } } } } } }`
|
|
824
|
+
|
|
825
|
+
const response = await fetch('https://<host>/graphql', {
|
|
826
|
+
method: 'POST',
|
|
827
|
+
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
|
828
|
+
body: JSON.stringify({ query }),
|
|
829
|
+
signal: AbortSignal.timeout(20_000),
|
|
830
|
+
})
|
|
831
|
+
if (!response.ok) process.exit(1)
|
|
832
|
+
const answer = (await response.json()) as { errors?: unknown }
|
|
833
|
+
if (answer.errors) process.exit(1)
|
|
834
|
+
console.log(JSON.stringify(answer))
|
|
835
|
+
|
|
836
|
+
export {}
|
|
778
837
|
```
|
|
779
838
|
|
|
780
839
|
```json
|
|
781
840
|
{
|
|
782
841
|
"name": "linear-eng-123",
|
|
783
|
-
"command": ["
|
|
842
|
+
"command": ["bun", "scripts/sources/linear.ts"],
|
|
784
843
|
"items": ".data.issues.nodes",
|
|
785
844
|
"map": {
|
|
786
845
|
"id": ".identifier",
|
|
@@ -813,19 +872,28 @@ Check with `--raw` which end of `comments.nodes` holds the newest comment; `[0]`
|
|
|
813
872
|
}
|
|
814
873
|
```
|
|
815
874
|
|
|
816
|
-
**Sentry.** Token: an auth token with `event:read` in `SENTRY_AUTH_TOKEN`; `<host>` is `sentry.io` or your own. The script, for example `scripts/sources/sentry.
|
|
875
|
+
**Sentry.** Token: an auth token with `event:read` in `SENTRY_AUTH_TOKEN`; `<host>` is `sentry.io` or your own. The script, for example `scripts/sources/sentry.ts`:
|
|
876
|
+
|
|
877
|
+
```ts
|
|
878
|
+
// scripts/sources/sentry.ts
|
|
879
|
+
const token = process.env.SENTRY_AUTH_TOKEN
|
|
880
|
+
if (!token) process.exit(1)
|
|
881
|
+
|
|
882
|
+
const response = await fetch(
|
|
883
|
+
'https://<host>/api/0/projects/<org>/<project>/issues/?query=is:unresolved&statsPeriod=24h',
|
|
884
|
+
{ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000) },
|
|
885
|
+
)
|
|
886
|
+
if (!response.ok) process.exit(1)
|
|
887
|
+
console.log(JSON.stringify(await response.json()))
|
|
817
888
|
|
|
818
|
-
|
|
819
|
-
#!/bin/sh
|
|
820
|
-
printf 'Authorization: Bearer %s\n' "$SENTRY_AUTH_TOKEN" |
|
|
821
|
-
curl -fsS --max-time 20 -H @- "https://<host>/api/0/projects/<org>/<project>/issues/?query=is:unresolved&statsPeriod=24h"
|
|
889
|
+
export {}
|
|
822
890
|
```
|
|
823
891
|
|
|
824
892
|
```json
|
|
825
893
|
{
|
|
826
894
|
"name": "errors",
|
|
827
895
|
"every": 10,
|
|
828
|
-
"command": ["
|
|
896
|
+
"command": ["bun", "scripts/sources/sentry.ts"],
|
|
829
897
|
"map": { "id": ".id", "updatedAt": ".lastSeen", "title": ".title", "url": ".permalink", "body": ".culprit" }
|
|
830
898
|
}
|
|
831
899
|
```
|
|
@@ -887,6 +955,7 @@ The herdr plugin `zerodice0.worktree-bootstrap`, when enabled and configured in
|
|
|
887
955
|
One block per lane of the chosen board, lanes that wait on you first:
|
|
888
956
|
|
|
889
957
|
- the agent's state: `❓ blocked` (waits for you), `📨 done` (the agent finished its answer, not the lane), `⏸ idle`, `⚡ working`, or `·` no agent
|
|
958
|
+
- which lanes are listed at all. A lane with an agent or an open pull request is drawn in full. A lane with a worktree but no agent is listed under `· no agent`. A lane without a worktree is listed under `· waiting`, with what it starts after, when a phase it names is still open in its plan or, if it names no plan, when its "starts after" cell says something other than "now" (`sofort`); otherwise it is finished and not shown. A lane whose name cell carries the cleaned-up mark (`abgeräumt`) is finished whatever its plan says. The board needs no extra column for this
|
|
890
959
|
- how full the agent's context is, and a bar with the lane's ticked tasks
|
|
891
960
|
- the branch, commits ahead and uncommitted files
|
|
892
961
|
- the lane's open pull request with its checks
|
|
@@ -1077,6 +1146,7 @@ What usually stays to configure is what no file in the repository says, for exam
|
|
|
1077
1146
|
- `git-guard` refuses a git command that hides `commit`, `push`, `rebase`, `reset`, `checkout` or `merge` from your permission rules (`git -C … push`, `sh -c "…"`).
|
|
1078
1147
|
- `watch-guard` refuses a `jh watch` start whose reports would go unseen.
|
|
1079
1148
|
- `merge-guard` asks you before `gh pr merge` and `gh workflow run` run, when they are spelled out ([Guarded commands](#user-content-guarded-commands-and-self-merge)).
|
|
1149
|
+
- `ask-hint` (event `Stop`) tells you when a turn ends in a question asked as running text and used no `AskUserQuestion` dialog. It only hints, never blocks, and stays silent in a lane session, which asks as a text block. Switch it off with `"hints": { "askUserQuestion": false }` in `.jackherd/config.json`.
|
|
1080
1150
|
- `format-on-edit` runs the project's own Biome on edited TS, JS and JSON files, when the project has it installed.
|
|
1081
1151
|
- **herdr plugin:** a plans pane and an action that opens it ([Installation](#user-content-project-install)).
|
|
1082
1152
|
|
|
@@ -35,6 +35,7 @@ var PLAN_VOCABULARY = {
|
|
|
35
35
|
phase: "Phase",
|
|
36
36
|
noReviewGate: "Kein Review-Gate",
|
|
37
37
|
laneClosed: "abger\xE4umt",
|
|
38
|
+
startsNow: ["sofort"],
|
|
38
39
|
phaseConnectors: ["danach", "und", "dann", "sowie"],
|
|
39
40
|
statusWords: {
|
|
40
41
|
done: { label: "Done", leading: ["done"], anywhere: ["abgeschlossen"] },
|
|
@@ -62,6 +63,7 @@ var PLAN_VOCABULARY = {
|
|
|
62
63
|
phase: "Phase",
|
|
63
64
|
noReviewGate: "No review gate",
|
|
64
65
|
laneClosed: "cleaned up",
|
|
66
|
+
startsNow: ["now", "immediately"],
|
|
65
67
|
phaseConnectors: ["then", "and", "after"],
|
|
66
68
|
statusWords: {
|
|
67
69
|
done: { label: "Done", leading: ["done"], anywhere: ["completed"] },
|
package/dist/config.d.ts
CHANGED
|
@@ -139,6 +139,12 @@ declare const jackherdConfigSchema: z.ZodObject<{
|
|
|
139
139
|
approvalMinutes: z.ZodDefault<z.ZodNumber>;
|
|
140
140
|
}, z.core.$strict>>;
|
|
141
141
|
}, z.core.$strict>>;
|
|
142
|
+
merge: z.ZodPrefault<z.ZodObject<{
|
|
143
|
+
withoutChecks: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
144
|
+
}, z.core.$strict>>;
|
|
145
|
+
hints: z.ZodPrefault<z.ZodObject<{
|
|
146
|
+
askUserQuestion: z.ZodDefault<z.ZodBoolean>;
|
|
147
|
+
}, z.core.$strict>>;
|
|
142
148
|
check: z.ZodOptional<z.ZodObject<{
|
|
143
149
|
lintFilesCommand: z.ZodDefault<z.ZodString>;
|
|
144
150
|
checks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PLAN_LANGUAGE
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-HQAONP4W.js";
|
|
4
4
|
|
|
5
5
|
// src/config/json-schema.ts
|
|
6
6
|
import { z as z3 } from "zod";
|
|
@@ -299,6 +299,20 @@ var coordinatorSchema = z2.strictObject({
|
|
|
299
299
|
alertAt: share.default(0.7)
|
|
300
300
|
}).refine(warnBelowAlert, WARN_BELOW_ALERT);
|
|
301
301
|
var guardedCommand = z2.string().regex(/^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*){1,3}$/, "a program and one to three plain words, such as gh pr merge");
|
|
302
|
+
var checkFreePattern = z2.string().regex(/^[A-Za-z0-9_.*/-]+$/, "path words with * and ** only, such as docs/**/*.md").refine(
|
|
303
|
+
(pattern) => !pattern.startsWith("/") && !pattern.split("/").includes(".."),
|
|
304
|
+
"a relative path that stays inside the repository"
|
|
305
|
+
).refine(
|
|
306
|
+
(pattern) => pattern.split("/").some((segment) => /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(segment)),
|
|
307
|
+
"a pattern with a whole path segment that is no wildcard, such as docs/**/*.md"
|
|
308
|
+
);
|
|
309
|
+
var mergeSchema = z2.strictObject({
|
|
310
|
+
/**
|
|
311
|
+
* Paths of pull requests that are merged without waiting for checks: when every file of the pull request matches one
|
|
312
|
+
* of them, `/orchestrate` merges at once and the merge guard does not ask. Empty means every merge waits for checks.
|
|
313
|
+
*/
|
|
314
|
+
withoutChecks: z2.array(checkFreePattern).default([])
|
|
315
|
+
});
|
|
302
316
|
var guardSchema = z2.strictObject({
|
|
303
317
|
/**
|
|
304
318
|
* The commands the merge guard hook asks the user about before they run, also behind options before the verb, a chain,
|
|
@@ -312,6 +326,10 @@ var guardSchema = z2.strictObject({
|
|
|
312
326
|
approvalMinutes: z2.number().int().min(1).max(1440).default(180)
|
|
313
327
|
}).prefault({})
|
|
314
328
|
});
|
|
329
|
+
var hintsSchema = z2.strictObject({
|
|
330
|
+
/** Whether the `ask-hint` hook points out a last message that ends in a question while the turn used no `AskUserQuestion`; a lane session is never hinted. */
|
|
331
|
+
askUserQuestion: z2.boolean().default(true)
|
|
332
|
+
});
|
|
315
333
|
var contextSchema = z2.strictObject({
|
|
316
334
|
/** Share of the context window from which a lane's fill shows yellow and `/implement` offers a new chat. */
|
|
317
335
|
warnAt: share.default(0.6),
|
|
@@ -387,6 +405,10 @@ var jackherdConfigSchema = z2.strictObject({
|
|
|
387
405
|
}).prefault({}),
|
|
388
406
|
/** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */
|
|
389
407
|
guard: guardSchema.prefault({}),
|
|
408
|
+
/** Read by `/orchestrate` and by the merge guard hook. */
|
|
409
|
+
merge: mergeSchema.prefault({}),
|
|
410
|
+
/** Read by the `ask-hint` hook. */
|
|
411
|
+
hints: hintsSchema.prefault({}),
|
|
390
412
|
/** Read by `jh check`; validated here too, so a typo fails every command alike. */
|
|
391
413
|
check: checkConfigSchema.optional()
|
|
392
414
|
});
|
package/dist/plan-format.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ type PlanVocabulary = {
|
|
|
55
55
|
noReviewGate: string;
|
|
56
56
|
/** Marks a lane as closed when its name cell contains it. */
|
|
57
57
|
laneClosed: string;
|
|
58
|
+
/** Words a starts-after cell begins with when the lane may start at once. */
|
|
59
|
+
startsNow: readonly string[];
|
|
58
60
|
/** Words that may stand between two phases of one entry (`P1 Rule und P2 Monitor`). */
|
|
59
61
|
phaseConnectors: readonly string[];
|
|
60
62
|
statusWords: Record<PlanStatus, StatusWord>;
|
package/dist/plan-format.js
CHANGED
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.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.5.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type HintInput = {
|
|
2
|
+
message: string
|
|
3
|
+
tools: string[]
|
|
4
|
+
lane: boolean
|
|
5
|
+
stopHookActive: boolean
|
|
6
|
+
enabled: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type HintDecision = { kind: 'silent' } | { kind: 'hint'; message: string }
|
|
10
|
+
|
|
11
|
+
const ASK_TOOL = 'AskUserQuestion'
|
|
12
|
+
|
|
13
|
+
/** A lane prompt names the lane in its first line; a lane started from a hand-over file reads the file first. */
|
|
14
|
+
const LANE_PROMPT = /^\s*(<pasted_content[^>]*>\s*)?(Du bist die Spur|You are the lane|Read `\.claude\/handover\/)/i
|
|
15
|
+
|
|
16
|
+
const HINT =
|
|
17
|
+
'Claude\'s last reply ends in a question, but no AskUserQuestion dialog was used. For choices with fixed answers, ask Claude to use the dialog. Turn this notice off with {"hints": {"askUserQuestion": false}} in .jackherd/config.json.'
|
|
18
|
+
|
|
19
|
+
/** The last character that counts is a `?`; closing emphasis, quotes and brackets after it do not hide it. */
|
|
20
|
+
export const endsWithQuestion = (message: string): boolean => /[??][\s*_"')\]“”’»«]*$/.test(message)
|
|
21
|
+
|
|
22
|
+
export const isLanePrompt = (firstPrompt: string): boolean => LANE_PROMPT.test(firstPrompt)
|
|
23
|
+
|
|
24
|
+
export const decideHint = (input: HintInput): HintDecision => {
|
|
25
|
+
if (!input.enabled || input.stopHookActive || input.lane) return { kind: 'silent' }
|
|
26
|
+
if (!endsWithQuestion(input.message) || input.tools.includes(ASK_TOOL)) return { kind: 'silent' }
|
|
27
|
+
return { kind: 'hint', message: HINT }
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Stop hook, shipped by jackherd. Points out, once per turn, a last message that ends in a question while the turn used
|
|
2
|
+
// no AskUserQuestion dialog. It only hints (a `systemMessage` for the user, exit 0) and never blocks the stop. Any failure
|
|
3
|
+
// inside the hook is silent (a module that does not load or a missing `bun` is not caught here): a hint must not fail a turn. The Stop input carries `last_assistant_message`, `prompt_id` and
|
|
4
|
+
// `transcript_path`; the tools of the turn and the lane prompt come from the transcript, read only when the message is a question.
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { decideHint, endsWithQuestion, isLanePrompt } from './decision'
|
|
7
|
+
import { isHintEnabled } from './settings'
|
|
8
|
+
import { readTurn } from './transcript'
|
|
9
|
+
|
|
10
|
+
type StopInput = {
|
|
11
|
+
last_assistant_message?: unknown
|
|
12
|
+
prompt_id?: unknown
|
|
13
|
+
transcript_path?: unknown
|
|
14
|
+
stop_hook_active?: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const hintFor = (input: StopInput) => {
|
|
18
|
+
if (typeof input.last_assistant_message !== 'string' || !endsWithQuestion(input.last_assistant_message)) return undefined
|
|
19
|
+
if (typeof input.transcript_path !== 'string' || input.stop_hook_active === true) return undefined
|
|
20
|
+
const projectDirectory = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
|
|
21
|
+
if (!isHintEnabled(projectDirectory)) return undefined
|
|
22
|
+
const promptId = typeof input.prompt_id === 'string' ? input.prompt_id : undefined
|
|
23
|
+
const turn = readTurn(readFileSync(input.transcript_path, 'utf8'), promptId)
|
|
24
|
+
return decideHint({
|
|
25
|
+
message: input.last_assistant_message,
|
|
26
|
+
tools: turn.tools,
|
|
27
|
+
lane: isLanePrompt(turn.firstPrompt),
|
|
28
|
+
stopHookActive: false,
|
|
29
|
+
enabled: true,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const decide = async () => {
|
|
34
|
+
try {
|
|
35
|
+
return hintFor(JSON.parse(await Bun.stdin.text()) as StopInput)
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const decision = await decide()
|
|
42
|
+
|
|
43
|
+
if (decision?.kind === 'hint') console.log(JSON.stringify({ systemMessage: decision.message }))
|
|
44
|
+
process.exit(0)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
6
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
7
|
+
|
|
8
|
+
const readHintsSection = (directories: string[]): Record<string, unknown> => {
|
|
9
|
+
const path = directories.map((directory) => join(directory, '.jackherd', 'config.json')).find((candidate) => existsSync(candidate))
|
|
10
|
+
if (!path) return {}
|
|
11
|
+
try {
|
|
12
|
+
const config: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
13
|
+
return isRecord(config) && isRecord(config.hints) ? config.hints : {}
|
|
14
|
+
} catch {
|
|
15
|
+
return {}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** On unless the first config that exists says `hints.askUserQuestion: false`; a malformed value keeps the hint on. */
|
|
20
|
+
export const isHintEnabled = (projectDirectory: string, home: string = homedir()): boolean =>
|
|
21
|
+
readHintsSection([projectDirectory, home]).askUserQuestion !== false
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type Turn = { firstPrompt: string; tools: string[] }
|
|
2
|
+
|
|
3
|
+
type Entry = {
|
|
4
|
+
type?: unknown
|
|
5
|
+
isMeta?: unknown
|
|
6
|
+
promptId?: unknown
|
|
7
|
+
message?: { content?: unknown }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type Block = { type?: unknown; text?: unknown; name?: unknown }
|
|
11
|
+
|
|
12
|
+
const parseEntries = (jsonl: string): Entry[] =>
|
|
13
|
+
jsonl.split('\n').flatMap((line) => {
|
|
14
|
+
try {
|
|
15
|
+
const entry: unknown = JSON.parse(line)
|
|
16
|
+
return typeof entry === 'object' && entry !== null ? [entry as Entry] : []
|
|
17
|
+
} catch {
|
|
18
|
+
return []
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const blocks = (entry: Entry): Block[] => {
|
|
23
|
+
const content = entry.message?.content
|
|
24
|
+
return Array.isArray(content) ? (content as Block[]) : []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The text a person typed: a string, or text parts. A tool result and the harness's own skill text are no prompt. */
|
|
28
|
+
const promptText = (entry: Entry): string | undefined => {
|
|
29
|
+
if (entry.type !== 'user' || entry.isMeta === true) return undefined
|
|
30
|
+
const content = entry.message?.content
|
|
31
|
+
if (typeof content === 'string') return content
|
|
32
|
+
const texts = blocks(entry).flatMap((block) => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : []))
|
|
33
|
+
return texts.length > 0 ? texts.join('\n') : undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const toolNames = (entry: Entry): string[] =>
|
|
37
|
+
entry.type === 'assistant'
|
|
38
|
+
? blocks(entry).flatMap((block) => (block.type === 'tool_use' && typeof block.name === 'string' ? [block.name] : []))
|
|
39
|
+
: []
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The first prompt of the session and the tools the current turn used. The turn starts at the last prompt carrying
|
|
43
|
+
* `promptId` (the Stop input's `prompt_id`), else at the last prompt of the transcript.
|
|
44
|
+
*/
|
|
45
|
+
export const readTurn = (jsonl: string, promptId: string | undefined): Turn => {
|
|
46
|
+
const entries = parseEntries(jsonl)
|
|
47
|
+
const prompts = entries.flatMap((entry, index) => (promptText(entry) === undefined ? [] : [index]))
|
|
48
|
+
const ofTurn = prompts.filter((index) => promptId !== undefined && entries[index].promptId === promptId)
|
|
49
|
+
const start = (ofTurn.length > 0 ? ofTurn : prompts).at(-1) ?? entries.length
|
|
50
|
+
const first = prompts[0]
|
|
51
|
+
return {
|
|
52
|
+
firstPrompt: first === undefined ? '' : (promptText(entries[first]) ?? ''),
|
|
53
|
+
tools: entries.slice(start).flatMap(toolNames),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a pull request changes nothing but files the project merges without waiting for checks
|
|
3
|
+
* (`merge.withoutChecks`). The file list comes from GitHub, never from an agent's text. Every doubt answers
|
|
4
|
+
* "no", so the guard asks as before.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runGh } from './gh'
|
|
8
|
+
import type { RepositoryFacts } from './pull-request'
|
|
9
|
+
|
|
10
|
+
/** What GitHub lists per file: the name, and the old name of a renamed file. */
|
|
11
|
+
const FILE_ENTRY_QUERY = '.[] | [.filename, .previous_filename] | @json'
|
|
12
|
+
|
|
13
|
+
/** GitHub lists at most this many files of a pull request and says nothing when it stops there. */
|
|
14
|
+
export const MAX_LISTED_FILES = 3000
|
|
15
|
+
|
|
16
|
+
const isControlCharacter = (character: string): boolean => {
|
|
17
|
+
const code = character.charCodeAt(0)
|
|
18
|
+
return code < 0x20 || code === 0x7f
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A repository path as GitHub lists it: relative, no empty, `.` or `..` segment, no backslash, no control character. */
|
|
22
|
+
const isPlainPath = (file: string): boolean =>
|
|
23
|
+
file !== '' &&
|
|
24
|
+
!file.startsWith('/') &&
|
|
25
|
+
!file.includes('\\') &&
|
|
26
|
+
![...file].some(isControlCharacter) &&
|
|
27
|
+
!file.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
28
|
+
|
|
29
|
+
const isNameOrNull = (value: unknown): value is string | null => value === null || typeof value === 'string'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The names a listing carries, the old name of a rename too, from one `[filename, previous]` JSON pair per line.
|
|
33
|
+
* The pairs must be exactly as many as the pull request changes files, and fewer than GitHub's cap: a list that
|
|
34
|
+
* was cut short would hide the files that are not in it. Anything else answers nothing.
|
|
35
|
+
*/
|
|
36
|
+
export const parseFileEntries = (stdout: string, changedFiles: number): string[] | undefined => {
|
|
37
|
+
if (changedFiles >= MAX_LISTED_FILES) return undefined
|
|
38
|
+
try {
|
|
39
|
+
const entries = stdout
|
|
40
|
+
.split('\n')
|
|
41
|
+
.filter((line) => line !== '')
|
|
42
|
+
.map((line): unknown => JSON.parse(line))
|
|
43
|
+
const pairs = entries.filter(
|
|
44
|
+
(entry): entry is [string, string | null] =>
|
|
45
|
+
Array.isArray(entry) && entry.length === 2 && typeof entry[0] === 'string' && isNameOrNull(entry[1]),
|
|
46
|
+
)
|
|
47
|
+
if (pairs.length !== entries.length || pairs.length !== changedFiles) return undefined
|
|
48
|
+
return pairs.flatMap(([filename, previous]) => (previous === null ? [filename] : [filename, previous]))
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True only for at least one file, at least one pattern, and every file a plain path that matches a pattern. */
|
|
55
|
+
export const allFilesMatch = (files: readonly string[], patterns: readonly string[]): boolean => {
|
|
56
|
+
if (files.length === 0 || patterns.length === 0) return false
|
|
57
|
+
const globs = patterns.map((pattern) => new Bun.Glob(pattern))
|
|
58
|
+
return files.every((file) => isPlainPath(file) && globs.some((glob) => glob.match(file)))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Asks `gh api` for the files of pull request <number> of `facts.repository`, named in full; any failure answers nothing.
|
|
63
|
+
*/
|
|
64
|
+
export const changedFilesOf = (pullRequest: number, directory: string, facts: RepositoryFacts): string[] | undefined => {
|
|
65
|
+
const listing = runGh(
|
|
66
|
+
['api', `repos/${facts.repository}/pulls/${pullRequest}/files?per_page=100`, '--paginate', '--jq', FILE_ENTRY_QUERY],
|
|
67
|
+
directory,
|
|
68
|
+
)
|
|
69
|
+
return listing === undefined ? undefined : parseFileEntries(listing, facts.changedFiles)
|
|
70
|
+
}
|
|
@@ -13,7 +13,8 @@ export type GuardPolicy = {
|
|
|
13
13
|
/** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
|
|
14
14
|
commands: readonly string[]
|
|
15
15
|
/**
|
|
16
|
-
* Whether the
|
|
16
|
+
* Whether the merge of this pull request may pass without a question: the user approved it, or every file of it
|
|
17
|
+
* matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
|
|
17
18
|
* A throw counts as not approved.
|
|
18
19
|
*/
|
|
19
20
|
isMergeApproved: (pullRequest: number) => boolean
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const LOOKUP_TIMEOUT_MS = 4000
|
|
2
|
+
|
|
3
|
+
/** Runs `gh` with these arguments in `directory`, without a shell; stdout on success, nothing on any failure or timeout. */
|
|
4
|
+
export const runGh = (args: readonly string[], directory: string): string | undefined => {
|
|
5
|
+
const result = Bun.spawnSync(['gh', ...args], {
|
|
6
|
+
cwd: directory,
|
|
7
|
+
timeout: LOOKUP_TIMEOUT_MS,
|
|
8
|
+
stdout: 'pipe',
|
|
9
|
+
stderr: 'ignore',
|
|
10
|
+
})
|
|
11
|
+
return result.exitCode === 0 ? result.stdout.toString() : undefined
|
|
12
|
+
}
|