jackherd 1.1.0 → 1.3.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 +15 -0
- package/README.md +36 -7
- package/dist/{chunk-R6MGHCD7.js → chunk-UI3AZNZF.js} +5 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +17 -1
- package/dist/plan-format.d.ts +7 -1
- package/dist/plan-format.js +3 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/git-guard/decision.ts +10 -79
- package/kit/hooks/hooks/merge-guard/approval.ts +107 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +286 -0
- package/kit/hooks/hooks/merge-guard/index.ts +56 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +33 -0
- package/kit/hooks/hooks/merge-guard/run.ts +60 -0
- package/kit/hooks/hooks/merge-guard/settings.ts +56 -0
- package/kit/hooks/hooks/merge-guard/worker.ts +14 -0
- package/kit/hooks/hooks/shared/program-position.ts +82 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +1 -1
- package/kit/hooks/hooks/watch-guard/starts.ts +1 -1
- package/kit/hooks/hooks.json +10 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +68 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +35 -0
- package/src/config/schema.ts +23 -0
- package/src/sync/command-context.ts +8 -4
- package/src/sync/command.ts +27 -9
- package/src/sync/if-changed.ts +18 -8
- package/src/sync/render.ts +4 -2
- package/src/sync/report.ts +21 -0
- package/src/sync/settings-migration.ts +45 -3
- package/src/sync/settings-offer.ts +8 -2
- package/src/sync/sync.ts +7 -3
- package/src/vocabulary/index.ts +10 -0
- package/src/watch/command.ts +7 -1
- package/src/watch/report-questions.ts +16 -2
- package/src/watch/update-available.ts +26 -0
- /package/kit/hooks/hooks/{git-guard → shared}/shell.ts +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
What changed for people who use jackherd, newest first.
|
|
4
4
|
|
|
5
|
+
## 1.3.0
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **Self-merge in the PR question.** The `orchestrate` skill's PR question has three options: you merge, you approve and the coordinator merges once the checks are green, or you change something first. On the second, the coordinator waits for the checks in the background, merges with `gh pr merge <number> --squash --delete-branch` on green, does not on red, and tells you in one line. The hook lets exactly that merge pass (`gh pr merge <number>` on its own with merge-method and delete-branch flags), and only for the branch you were asked about, for `guard.selfMerge.approvalMinutes` (default 180): it reads your answer from the session transcript, never from the agent's text, and asks GitHub which branch the pull request carries. `guard.selfMerge.offer: false` takes the option away; a project supplement can block it per tier.
|
|
9
|
+
- **Shorter questions in the dialog.** The `orchestrate` skill now asks in one short sentence of 15 words at most, with the context in the chat before it (three lines), options of a few words and mostly two or three of them, and several questions in one call only when they are independent. The pull request question keeps three bullets of context in the chat and a one-line question. A test measures the example dialogs in the skill.
|
|
10
|
+
- **`jh sync` brings the hook.** A project install gets `merge-guard` in `.claude/hooks/` and an entry in `.claude/settings.json`; a global install ships no hook.
|
|
11
|
+
|
|
12
|
+
## 1.2.0
|
|
13
|
+
|
|
14
|
+
- **A running session hears about a new release.** The watcher asks npm once a day, on the timestamp of the `SessionStart` hook, and prints `=== update available <old> → <new>` once per version when there is a newer one; with nothing new it prints nothing. The `orchestrate` skill asks you (update now / not now) and runs `jh update` only after a yes.
|
|
15
|
+
- **The hook also runs after `/clear`, `/resume` and compaction.** Its matcher is now `startup|resume|clear|compact`; `jh sync --global` moves a hook that matches `startup` only, exactly as an older install wrote it, and touches no other entry in `~/.claude/settings.json`.
|
|
16
|
+
- **A silent npm is asked again after an hour.** Before, one unanswered question paused the check for a day. Nothing is printed on a failure.
|
|
17
|
+
- **No `questions:` line for an empty section.** A `## Questions` section that only says "Keine." or "None." counts as no question, and the report keeps its full head.
|
|
18
|
+
- **README.** Three badges at the top: npm version, license, dependencies.
|
|
19
|
+
|
|
5
20
|
## 1.1.0
|
|
6
21
|
|
|
7
22
|
- **Questions in a lane's report reach you.** The watcher cut a new report to its first 15 lines, and the questions, which stand at the end, fell away. It now shows a `## Questions` section (also `## Fragen`, any level, any text after the word) whole, wherever it stands, under a `questions: <n>` line, within a budget of 20 lines and 1500 characters (over it, each question keeps the start of its first line and a closing line says how many were left out). A report with such a section gets a shorter head (8 lines, 900 characters: the diff stat and the checks), so the message stays about as long as before. The `orchestrate` skill makes every question a duty in the same wake-up: answered in the lane, or put to you, and a question the lane answered is reported to you in one line.
|
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
<br>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
|
+
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies)
|
|
10
|
+
|
|
9
11
|
**Plan. Delegate. Ship.** Your agents do the work. Jack keeps them together.
|
|
10
12
|
|
|
11
13
|
Large tasks outgrow one chat, the context fills up, parallel sessions edit the same files, and
|
|
@@ -168,7 +170,7 @@ When `init` creates a new config, it asks:
|
|
|
168
170
|
| Use it when | the team shares the kit through the repository | the repository must stay untouched, not even untracked files |
|
|
169
171
|
| Lives in | the project's `.claude/` | `~/.claude/` |
|
|
170
172
|
| Skill names | `/plan`, `/implement`, `/orchestrate`, … | `/jh-plan`, `/jh-implement`, `/jh-orchestrate`, … |
|
|
171
|
-
| Hooks | `git-guard`, `watch-guard`, `format-on-edit` | none; `init --global` offers only a `SessionStart` update notice |
|
|
173
|
+
| Hooks | `git-guard`, `watch-guard`, `merge-guard`, `format-on-edit` | none; `init --global` offers only a `SessionStart` update notice |
|
|
172
174
|
| Config | `.jackherd/config.json` | `~/.jackherd/config.json`, used where a repository has none |
|
|
173
175
|
| Update | `jh sync` | `jh update` |
|
|
174
176
|
|
|
@@ -260,10 +262,12 @@ A running `plans` view restarts itself with the new code, and a running `watch`
|
|
|
260
262
|
`jh sync --global --if-changed` is meant to run at the start of every session. It syncs `~/.claude` only when the installed package is not the version the global lock was written by, so after `bun add -g jackherd@<version>` the next session brings the skills up to date. Once a day it asks npm for the newest release and, when there is one, prints a single line of this form:
|
|
261
263
|
|
|
262
264
|
```text
|
|
263
|
-
jh 1.
|
|
265
|
+
jh 1.2.0 is on npm, 1.1.0 is installed. Ask the user with AskUserQuestion whether to run `jh update` now (update now / not now), and run it only after a yes.
|
|
264
266
|
```
|
|
265
267
|
|
|
266
|
-
It never installs anything itself: the session asks you, and `jh update` runs only after your yes. With nothing to do it prints nothing and exits 0; without a global install it syncs nothing. The time of the last npm question is kept in `~/.jackherd/update-check.json`.
|
|
268
|
+
It never installs anything itself: the session asks you, and `jh update` runs only after your yes. With nothing to do it prints nothing and exits 0; without a global install it syncs nothing. The time of the last npm question is kept in `~/.jackherd/update-check.json`. If npm does not answer (3 seconds), the question is asked again after an hour, not after a day, and nothing is printed meanwhile.
|
|
269
|
+
|
|
270
|
+
The hook runs when a session starts, resumes, is cleared or is compacted (`startup|resume|clear|compact`), so a hand-over by `/clear` sees the line too; the daily timestamp makes the extra runs free. A session that runs for days is covered by [the watcher](#user-content-the-watcher), which looks at npm once a day on the same timestamp. `jh sync --global` moves a hook that still reads `startup` only, exactly as an older install wrote it, to the new matcher; a hook of your own, or one with another command or extra keys, stays as it is.
|
|
267
271
|
|
|
268
272
|
`init --global` asks once whether to add this hook to `~/.claude/settings.json`, appends it as a group of its own beside your other hooks, and does nothing when it is there already. A file it cannot read or merge into, or a symlink, stays as it is; the entry to add by hand:
|
|
269
273
|
|
|
@@ -271,7 +275,7 @@ It never installs anything itself: the session asks you, and `jh update` runs on
|
|
|
271
275
|
{
|
|
272
276
|
"hooks": {
|
|
273
277
|
"SessionStart": [
|
|
274
|
-
{ "matcher": "startup", "hooks": [{ "type": "command", "command": "jh sync --global --if-changed" }] }
|
|
278
|
+
{ "matcher": "startup|resume|clear|compact", "hooks": [{ "type": "command", "command": "jh sync --global --if-changed" }] }
|
|
275
279
|
]
|
|
276
280
|
}
|
|
277
281
|
}
|
|
@@ -495,7 +499,7 @@ Lanes ask Jack, not you: the coordinator checks their questions against the plan
|
|
|
495
499
|
3. **Runs dev servers.** A lane whose phase changes something a user sees gets its dev server in a pane of its own when the lane starts, never in the lane's shell.
|
|
496
500
|
4. **Vets questions.** A lane never opens a dialog. It ends its turn with a text block: the question, the options, its recommendation, the evidence at `file:line`. The coordinator checks the premises against the code and the plan, answers what the plan or a rule already settles, and brings only the open decisions to you, each as a clickable choice. A question in a lane's report is closed in the same turn, in the lane or with you. A draft you review (a README, a page) goes in rounds: you pick the remarks, the lane changes it, you see it again, and the push waits for your approval. Your decision goes into the plan or the board in the same turn, with the date, so it is visible in the plan view and survives the chat.
|
|
497
501
|
5. **Reviews results.** When a lane is `done` or `idle`, it checks the diff against the lane's `Writes` set and that checks are quoted with numbers, not claimed. Then it starts the lane's next ready phase or names the reason to wait.
|
|
498
|
-
6. **Asks once per PR.** A lane works uncommitted until its PR is ready. The coordinator then asks you once, with the diff summary, the checks with numbers, open points and the commit message; for a visible change it first walks the changed path in the browser. That approval covers the commits, the push and the PR.
|
|
502
|
+
6. **Asks once per PR.** A lane works uncommitted until its PR is ready. The coordinator then asks you once, with the diff summary, the checks with numbers, open points and the commit message; for a visible change it first walks the changed path in the browser. That approval covers the commits, the push and the PR. The question has three options: you merge, you approve and let the coordinator merge once the checks are green ([self-merge](#user-content-guarded-commands-and-self-merge)), or you change something first.
|
|
499
503
|
7. **Keeps the merge order.** Merge order is the board's, and the documentation PR of a fresh plan merges first. Afterwards the lanes rebase as after any merge, and the coordinator removes the copies of the plan files in the main checkout that equal the merged ones, so the next update of the checkout does not stop on them. After each merge the remaining lanes fetch and rebase on the base branch and re-run their checks; a lock-file conflict is never resolved by hand. Each lane gets its next phase or a named reason to wait.
|
|
500
504
|
8. **Closes lanes.** After a lane's last merge and your agreement, it ends the agent, proves the worktree holds nothing unmerged, removes the worktree and the branch, and notes the end in the board.
|
|
501
505
|
|
|
@@ -504,11 +508,34 @@ Lanes ask Jack, not you: the coordinator checks their questions against the plan
|
|
|
504
508
|
| On its own | After your approval | Never |
|
|
505
509
|
| --- | --- | --- |
|
|
506
510
|
| Starting a ready phase in a new or running lane (one line to you afterwards) | Commits, push and PR: one approval per PR, the documentation PR for a fresh plan included | Implementing lane work itself |
|
|
507
|
-
| Answering a question the plan, the board or a rule settles (reported in one line) | Every open decision a lane raises | Merging, unless a project supplement names PRs it may merge |
|
|
511
|
+
| Answering a question the plan, the board or a rule settles (reported in one line) | Every open decision a lane raises | Merging, unless a project supplement names PRs it may merge or you picked self-merge for the PR |
|
|
508
512
|
| Rebasing lanes after a merge, with their checks re-run | Database migrations and writes against a shared environment | Confirming a chained git command or storing a permission |
|
|
509
513
|
| Writing your decisions into the plan or the board | Ending an agent, closing a lane, a hand-over to a fresh chat | Steering herdr from outside herdr |
|
|
510
514
|
|
|
511
|
-
Who merges follows the project's own rules: by default you, unless the project supplement (`.claude/project/orchestrate.md`) names PRs the coordinator may merge itself and under which conditions.
|
|
515
|
+
Who merges follows the project's own rules: by default you, unless the project supplement (`.claude/project/orchestrate.md`) names PRs the coordinator may merge itself and under which conditions, or you picked self-merge for that PR.
|
|
516
|
+
|
|
517
|
+
#### Guarded commands and self-merge
|
|
518
|
+
|
|
519
|
+
A merge or a release is the step nobody takes back. In a project install the `merge-guard` hook asks you before `gh pr merge` and `gh workflow run` (the usual production release) run: it recognises them when the command spells them out: behind options before the verb (`gh -R x pr merge`), a chain (`a && gh pr merge 12`), a wrapper (also one it does not know, such as `mise exec -- gh pr merge 12`, as any command whose words contain the guarded words in this order), `sh -c` and `eval`, and shows the dialog even in auto mode. A quoted text that names the words, such as a commit message, passes, and so does everything else; the hook prints nothing unless it asks.
|
|
520
|
+
|
|
521
|
+
```json
|
|
522
|
+
{
|
|
523
|
+
"guard": {
|
|
524
|
+
"commands": ["gh pr merge", "gh workflow run"],
|
|
525
|
+
"selfMerge": { "offer": true, "approvalMinutes": 180 }
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
- `guard.commands` lists the guarded commands as words, a program and one to three plain words after it: letters, digits and `-`, no `:` and no option. These two are the default; `git push` is not on the list. An empty list switches the guard off.
|
|
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
|
+
- `guard.selfMerge.approvalMinutes` (default 180) is how long that answer lets `gh pr merge <number>` pass without a second dialog.
|
|
533
|
+
|
|
534
|
+
**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
|
+
|
|
536
|
+
**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.
|
|
537
|
+
|
|
538
|
+
**Its limit.** The hook reads the command as written, so it does not see what a command runs behind its back: a script file or a project script (`bash ./release.sh`, `./release.sh`, `bun run release`, `make release`) that calls `gh workflow run`, a shell alias or function, `gh alias` and git aliases, text that is encoded or assembled at run time, and a wrapper that spells the words differently. Add the script runners you use to `guard.commands`, or protect the repository itself. The agent runs with your account, so it can also edit a transcript, drop the hook from `.claude/settings.json` or merge through `gh api`. The guard stops a merge or a release by mistake, by a misread instruction or a mistyped command; it does not stop an agent that sets out to get around it. For that, use branch protection and required reviews on the repository. The approval covers the branch, not the commits you saw, for the configured minutes. A global install ships no hook, so there the skill text alone asks you. If Bun cannot start (a process out of file descriptors, say), the hook cannot run and every Bash call is blocked, on purpose: a guard that cannot run does not wave commands through.
|
|
512
539
|
|
|
513
540
|
A permission dialog inside a lane the coordinator confirms itself only for the commit and push of the lane's own branch under its PR approval, and for the fetch, rebase and lock-file steps after a merge, always with the one-time option. Every other dialog goes to you.
|
|
514
541
|
|
|
@@ -528,6 +555,7 @@ Why it exists: the coordinator is a chat, and it only acts when something arrive
|
|
|
528
555
|
- a deploy preview for a lane's new head commit, with its link (`watch.previews`)
|
|
529
556
|
- a new or changed entry of an external service, such as a ticket comment, read by a command the config names (`watch.sources`)
|
|
530
557
|
- `gh` became unreachable
|
|
558
|
+
- npm has a newer jackherd than the one running: `=== update available <old> → <new>`, one line, once per version. The watcher asks npm once a day on the timestamp the `SessionStart` hook uses (`~/.jackherd/update-check.json`), so the two never ask twice a day or report twice; with nothing newer it prints nothing. The coordinator asks you and runs `jh update` only after your yes
|
|
531
559
|
- a lane's context fill rose to `warn` or `alert` (`=== lane <name> alert 47 %`, with a duty line at `alert`: request the lane's hand-over at its next phase boundary), against the lanes' thresholds in `plans.context`
|
|
532
560
|
- the coordinator's own context fill rose to `warn` or `alert`, so it hands itself over in time
|
|
533
561
|
- `lane added` / `lane removed` when a worktree with an agent or a board row appears or goes
|
|
@@ -1048,6 +1076,7 @@ What usually stays to configure is what no file in the repository says, for exam
|
|
|
1048
1076
|
- **Hooks** (project install only):
|
|
1049
1077
|
- `git-guard` refuses a git command that hides `commit`, `push`, `rebase`, `reset`, `checkout` or `merge` from your permission rules (`git -C … push`, `sh -c "…"`).
|
|
1050
1078
|
- `watch-guard` refuses a `jh watch` start whose reports would go unseen.
|
|
1079
|
+
- `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)).
|
|
1051
1080
|
- `format-on-edit` runs the project's own Biome on edited TS, JS and JSON files, when the project has it installed.
|
|
1052
1081
|
- **herdr plugin:** a plans pane and an action that opens it ([Installation](#user-content-project-install)).
|
|
1053
1082
|
|
|
@@ -4,6 +4,10 @@ var LANGUAGE_NAME = {
|
|
|
4
4
|
[PLAN_LANGUAGE.en]: "English",
|
|
5
5
|
[PLAN_LANGUAGE.de]: "German"
|
|
6
6
|
};
|
|
7
|
+
var SELF_MERGE_LABEL = {
|
|
8
|
+
[PLAN_LANGUAGE.en]: "Approve, and merge it yourself after green checks",
|
|
9
|
+
[PLAN_LANGUAGE.de]: "Freigeben, und nach gr\xFCnen Checks selbst mergen"
|
|
10
|
+
};
|
|
7
11
|
var isPlanLanguage = (value) => Object.values(PLAN_LANGUAGE).includes(value);
|
|
8
12
|
var PLAN_STATUS = {
|
|
9
13
|
done: "done",
|
|
@@ -84,6 +88,7 @@ var sectionHeading = (language, section) => `## ${PLAN_VOCABULARY[language].sect
|
|
|
84
88
|
export {
|
|
85
89
|
PLAN_LANGUAGE,
|
|
86
90
|
LANGUAGE_NAME,
|
|
91
|
+
SELF_MERGE_LABEL,
|
|
87
92
|
isPlanLanguage,
|
|
88
93
|
PLAN_STATUS,
|
|
89
94
|
PLAN_VOCABULARY,
|
package/dist/config.d.ts
CHANGED
|
@@ -132,6 +132,13 @@ declare const jackherdConfigSchema: z.ZodObject<{
|
|
|
132
132
|
reportOn: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
133
133
|
}, z.core.$strict>>>;
|
|
134
134
|
}, z.core.$strict>>;
|
|
135
|
+
guard: z.ZodPrefault<z.ZodObject<{
|
|
136
|
+
commands: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
137
|
+
selfMerge: z.ZodPrefault<z.ZodObject<{
|
|
138
|
+
offer: z.ZodDefault<z.ZodBoolean>;
|
|
139
|
+
approvalMinutes: z.ZodDefault<z.ZodNumber>;
|
|
140
|
+
}, z.core.$strict>>;
|
|
141
|
+
}, z.core.$strict>>;
|
|
135
142
|
check: z.ZodOptional<z.ZodObject<{
|
|
136
143
|
lintFilesCommand: z.ZodDefault<z.ZodString>;
|
|
137
144
|
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-UI3AZNZF.js";
|
|
4
4
|
|
|
5
5
|
// src/config/json-schema.ts
|
|
6
6
|
import { z as z3 } from "zod";
|
|
@@ -298,6 +298,20 @@ var coordinatorSchema = z2.strictObject({
|
|
|
298
298
|
/** Share of the coordinator's context window from which the watcher reports `coordinator alert`. */
|
|
299
299
|
alertAt: share.default(0.7)
|
|
300
300
|
}).refine(warnBelowAlert, WARN_BELOW_ALERT);
|
|
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 guardSchema = z2.strictObject({
|
|
303
|
+
/**
|
|
304
|
+
* The commands the merge guard hook asks the user about before they run, also behind options before the verb, a chain,
|
|
305
|
+
* a wrapper or `sh -c`. An empty list switches the guard off.
|
|
306
|
+
*/
|
|
307
|
+
commands: z2.array(guardedCommand).default(["gh pr merge", "gh workflow run"]),
|
|
308
|
+
selfMerge: z2.strictObject({
|
|
309
|
+
/** Whether `/orchestrate` offers "merge it yourself after green checks" in the pull request question; off also stops the hook honouring such an answer. */
|
|
310
|
+
offer: z2.boolean().default(true),
|
|
311
|
+
/** How long an answer to that question lets `gh pr merge <number>` pass without asking, counted from the answer. */
|
|
312
|
+
approvalMinutes: z2.number().int().min(1).max(1440).default(180)
|
|
313
|
+
}).prefault({})
|
|
314
|
+
});
|
|
301
315
|
var contextSchema = z2.strictObject({
|
|
302
316
|
/** Share of the context window from which a lane's fill shows yellow and `/implement` offers a new chat. */
|
|
303
317
|
warnAt: share.default(0.6),
|
|
@@ -371,6 +385,8 @@ var jackherdConfigSchema = z2.strictObject({
|
|
|
371
385
|
previews: previewsSchema.optional(),
|
|
372
386
|
sources: z2.array(sourceSchema).refine(hasUniqueNames, "source names must be unique").default([])
|
|
373
387
|
}).prefault({}),
|
|
388
|
+
/** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */
|
|
389
|
+
guard: guardSchema.prefault({}),
|
|
374
390
|
/** Read by `jh check`; validated here too, so a typo fails every command alike. */
|
|
375
391
|
check: checkConfigSchema.optional()
|
|
376
392
|
});
|
package/dist/plan-format.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ declare const PLAN_LANGUAGE: {
|
|
|
5
5
|
type PlanLanguage = (typeof PLAN_LANGUAGE)[keyof typeof PLAN_LANGUAGE];
|
|
6
6
|
/** The language's name as a reply or a question says it. */
|
|
7
7
|
declare const LANGUAGE_NAME: Record<PlanLanguage, string>;
|
|
8
|
+
/**
|
|
9
|
+
* The option of the pull request question that approves the PR and a merge by the coordinator once the checks are
|
|
10
|
+
* green. The merge guard hook matches the user's answer against this exact text, so it is fixed per language and not
|
|
11
|
+
* a config value.
|
|
12
|
+
*/
|
|
13
|
+
declare const SELF_MERGE_LABEL: Record<PlanLanguage, string>;
|
|
8
14
|
declare const isPlanLanguage: (value: string) => value is PlanLanguage;
|
|
9
15
|
declare const PLAN_STATUS: {
|
|
10
16
|
readonly done: "done";
|
|
@@ -322,4 +328,4 @@ declare const readSprintDocument: (markdown: string, language: PlanLanguage) =>
|
|
|
322
328
|
*/
|
|
323
329
|
declare const readPhaseDependencies: (planMarkdown: string, language: PlanLanguage) => Record<string, string>;
|
|
324
330
|
|
|
325
|
-
export { type ChecklistItem, type DeferredItem, type DriftFinding, LANGUAGE_NAME, type LaneProgress, type MergedPhaseWithOpenBoxes, type MisplacedDeferral, PHASE_ID_PREFIX, PLAN_LANGUAGE, PLAN_STATUS, PLAN_VOCABULARY, type PhaseProgress, type PhaseTally, type PhaseWithoutGate, type PlanHeader, type PlanLanguage, type PlanPhase, type PlanStatus, type PlanStep, type PlanVocabulary, type PlansByHref, type RowWithoutPhase, type SprintDocument, type SprintLane, type SprintPlanRow, type SprintPlanTable, type StatusWord, type StepProgress, activeItem, boardRowsWithoutPhase, classifyStatus, countDecisions, currentPhase, currentPhaseOf, deferralTarget, endsPhase, findDrift, hasLanesTable, isCheckboxLine, isOpenCheckboxLine, isPlanLanguage, isReviewGateLine, isTickedUnmerged, keptPhaseIds, laneNameOf, laneProgress, mergedPhasesWithOpenBoxes, misplacedDeferrals, newPhasesWithoutReviewGate, parsePlanPhases, parseSprintLanes, phaseHeading, phaseHeadingPattern, phaseIdsOfCell, phaseIdsOfPlan, readPhaseDependencies, readPlanHeader, readSprintDocument, resolvePlanPath, sectionHeading, shortTitle, sprintTitleOf, sumPhases, tallyPhases, withoutRepeats };
|
|
331
|
+
export { type ChecklistItem, type DeferredItem, type DriftFinding, LANGUAGE_NAME, type LaneProgress, type MergedPhaseWithOpenBoxes, type MisplacedDeferral, PHASE_ID_PREFIX, PLAN_LANGUAGE, PLAN_STATUS, PLAN_VOCABULARY, type PhaseProgress, type PhaseTally, type PhaseWithoutGate, type PlanHeader, type PlanLanguage, type PlanPhase, type PlanStatus, type PlanStep, type PlanVocabulary, type PlansByHref, type RowWithoutPhase, SELF_MERGE_LABEL, type SprintDocument, type SprintLane, type SprintPlanRow, type SprintPlanTable, type StatusWord, type StepProgress, activeItem, boardRowsWithoutPhase, classifyStatus, countDecisions, currentPhase, currentPhaseOf, deferralTarget, endsPhase, findDrift, hasLanesTable, isCheckboxLine, isOpenCheckboxLine, isPlanLanguage, isReviewGateLine, isTickedUnmerged, keptPhaseIds, laneNameOf, laneProgress, mergedPhasesWithOpenBoxes, misplacedDeferrals, newPhasesWithoutReviewGate, parsePlanPhases, parseSprintLanes, phaseHeading, phaseHeadingPattern, phaseIdsOfCell, phaseIdsOfPlan, readPhaseDependencies, readPlanHeader, readSprintDocument, resolvePlanPath, sectionHeading, shortTitle, sprintTitleOf, sumPhases, tallyPhases, withoutRepeats };
|
package/dist/plan-format.js
CHANGED
|
@@ -3,11 +3,12 @@ import {
|
|
|
3
3
|
PLAN_LANGUAGE,
|
|
4
4
|
PLAN_STATUS,
|
|
5
5
|
PLAN_VOCABULARY,
|
|
6
|
+
SELF_MERGE_LABEL,
|
|
6
7
|
classifyStatus,
|
|
7
8
|
isPlanLanguage,
|
|
8
9
|
phaseHeading,
|
|
9
10
|
sectionHeading
|
|
10
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-UI3AZNZF.js";
|
|
11
12
|
|
|
12
13
|
// src/plan-format/terminal-text.ts
|
|
13
14
|
var UNSAFE_RANGES = [
|
|
@@ -596,6 +597,7 @@ export {
|
|
|
596
597
|
PLAN_LANGUAGE,
|
|
597
598
|
PLAN_STATUS,
|
|
598
599
|
PLAN_VOCABULARY,
|
|
600
|
+
SELF_MERGE_LABEL,
|
|
599
601
|
activeItem,
|
|
600
602
|
boardRowsWithoutPhase,
|
|
601
603
|
classifyStatus,
|
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.3.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { posix } from 'node:path'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ASSIGNMENT,
|
|
4
|
+
inlineScript,
|
|
5
|
+
isOption,
|
|
6
|
+
isProgramPosition,
|
|
7
|
+
programName,
|
|
8
|
+
readsScriptFromInput,
|
|
9
|
+
ruleStart,
|
|
10
|
+
} from '../shared/program-position'
|
|
11
|
+
import { parseShell, ShellParseError, type SimpleCommand, type Word } from '../shared/shell'
|
|
3
12
|
|
|
4
13
|
export type GitGuardDecision = { kind: 'pass' } | { kind: 'deny'; reason: string }
|
|
5
14
|
|
|
@@ -133,16 +142,6 @@ const OPTIONS_WITH_VALUE = new Set([
|
|
|
133
142
|
const CONFIG_OPTIONS = new Set(['-c', '--config-env'])
|
|
134
143
|
const CONFIG_ENV_ATTACHED = '--config-env='
|
|
135
144
|
|
|
136
|
-
/**
|
|
137
|
-
* Words Claude Code strips before it matches a Bash rule, so they do not hide the command. Its
|
|
138
|
-
* permission matching also reaches into control-flow bodies, which the reserved words open.
|
|
139
|
-
*/
|
|
140
|
-
const RULE_TRANSPARENT_WRAPPERS = new Set(['time', 'nohup', 'nice', 'command', 'builtin', 'noglob'])
|
|
141
|
-
const RESERVED_WORDS = new Set(['!', '{', '}', 'if', 'then', 'elif', 'else', 'fi', 'do', 'done', 'while', 'until'])
|
|
142
|
-
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
|
|
143
|
-
const DURATION = /^\d+(\.\d+)?[smhd]?$/
|
|
144
|
-
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'fish'])
|
|
145
|
-
const SHELL_COMMAND_FLAG = /^-[a-z]*c[a-z]*$/
|
|
146
145
|
const INLINE_ALIAS = /^alias\./i
|
|
147
146
|
/** Variables that make git or its hook manager skip the hooks. */
|
|
148
147
|
const HOOK_SKIPPING_VARIABLE = /^(HUSKY|LEFTHOOK|SKIP)\+?=/
|
|
@@ -150,19 +149,6 @@ const HOOK_SKIPPING_VARIABLE = /^(HUSKY|LEFTHOOK|SKIP)\+?=/
|
|
|
150
149
|
const REDIRECTING_VARIABLE = /^(GIT_CONFIG\w*|GIT_DIR|GIT_WORK_TREE|GIT_INDEX_FILE)\+?=/
|
|
151
150
|
/** An option the shell rewrites before git sees it: `--forc{e,}` reaches git as `--force`. */
|
|
152
151
|
const BRACE_IN_OPTION = /^-.*\{.*(,|\.\.).*\}/
|
|
153
|
-
/** Programs that run their arguments as a command, so a `git` right after them is the program. */
|
|
154
|
-
const COMMAND_RUNNERS = new Set([
|
|
155
|
-
...RULE_TRANSPARENT_WRAPPERS,
|
|
156
|
-
'env',
|
|
157
|
-
'sudo',
|
|
158
|
-
'doas',
|
|
159
|
-
'exec',
|
|
160
|
-
'xargs',
|
|
161
|
-
'timeout',
|
|
162
|
-
'stdbuf',
|
|
163
|
-
'setsid',
|
|
164
|
-
'watch',
|
|
165
|
-
])
|
|
166
152
|
const PROTECTED_MENTION = new RegExp(`\\bgit\\b[\\s\\S]*\\b(${Object.values(PROTECTED_VERB).join('|')})\\b`)
|
|
167
153
|
|
|
168
154
|
const isProtectedVerb = (value: string): value is ProtectedVerb =>
|
|
@@ -170,38 +156,7 @@ const isProtectedVerb = (value: string): value is ProtectedVerb =>
|
|
|
170
156
|
|
|
171
157
|
const deny = (reason: string): GitGuardDecision => ({ kind: 'deny', reason })
|
|
172
158
|
|
|
173
|
-
const transparentWidth = (words: SimpleCommand, index: number): number => {
|
|
174
|
-
const { value, literal } = words[index]
|
|
175
|
-
if (ASSIGNMENT.test(value)) return 1
|
|
176
|
-
if (!literal) return 0
|
|
177
|
-
if (RESERVED_WORDS.has(value) || RULE_TRANSPARENT_WRAPPERS.has(value)) return 1
|
|
178
|
-
return value === 'timeout' && DURATION.test(words[index + 1]?.value ?? '') ? 2 : 0
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** The index of the word a permission rule compares against: past assignments and stripped wrappers. */
|
|
182
|
-
const ruleStart = (words: SimpleCommand): number => {
|
|
183
|
-
let index = 0
|
|
184
|
-
while (index < words.length) {
|
|
185
|
-
const width = transparentWidth(words, index)
|
|
186
|
-
if (width === 0) break
|
|
187
|
-
index += width
|
|
188
|
-
}
|
|
189
|
-
return index
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** The program a word names, as a case-insensitive file system would find it. */
|
|
193
|
-
const programName = (word: Word): string =>
|
|
194
|
-
posix
|
|
195
|
-
.basename(word.value.replace(/^=/, ''))
|
|
196
|
-
.toLowerCase()
|
|
197
|
-
.replace(/\.exe$/, '')
|
|
198
|
-
|
|
199
159
|
const isGit = (word: Word): boolean => programName(word) === 'git'
|
|
200
|
-
|
|
201
|
-
/** At the rule start, or anywhere after a runner such as `sudo -u me` or `env FOO=1`: git runs as the program. */
|
|
202
|
-
const isProgramPosition = (words: SimpleCommand, index: number): boolean =>
|
|
203
|
-
index === ruleStart(words) || words.slice(0, index).some((word) => COMMAND_RUNNERS.has(programName(word)))
|
|
204
|
-
|
|
205
160
|
const gitPosition = (words: SimpleCommand, index: number, context: ScriptContext): GitPosition => {
|
|
206
161
|
const word = words[index]
|
|
207
162
|
const plain = context === SCRIPT_CONTEXT.top && index === ruleStart(words) && word.literal && word.value === 'git'
|
|
@@ -209,8 +164,6 @@ const gitPosition = (words: SimpleCommand, index: number, context: ScriptContext
|
|
|
209
164
|
return isProgramPosition(words, index) ? GIT_POSITION.program : GIT_POSITION.argument
|
|
210
165
|
}
|
|
211
166
|
|
|
212
|
-
const isOption = (word: Word | undefined): boolean => word?.value.startsWith('-') ?? false
|
|
213
|
-
|
|
214
167
|
const readInvocation = (words: SimpleCommand, gitIndex: number): GitInvocation | undefined => {
|
|
215
168
|
const options: Word[] = []
|
|
216
169
|
const directories: string[] = []
|
|
@@ -342,28 +295,6 @@ const judgeInvocation = (invocation: GitInvocation, position: GitPosition): GitG
|
|
|
342
295
|
return literal || position === GIT_POSITION.argument ? PASS : deny(VERB_HINT)
|
|
343
296
|
}
|
|
344
297
|
|
|
345
|
-
const isShell = (word: Word): boolean => SHELLS.has(programName(word))
|
|
346
|
-
|
|
347
|
-
/** The script word after `-c`: the first one that is neither an option nor `--`. */
|
|
348
|
-
const shellCommandScript = (words: SimpleCommand, shellIndex: number): string | undefined => {
|
|
349
|
-
const flag = words.findIndex((word, position) => position > shellIndex && SHELL_COMMAND_FLAG.test(word.value))
|
|
350
|
-
if (flag === -1) return undefined
|
|
351
|
-
return words.slice(flag + 1).find((word) => !isOption(word))?.value
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const inlineScript = (words: SimpleCommand, index: number): string | undefined => {
|
|
355
|
-
const word = words[index]
|
|
356
|
-
if (word.literal && word.value === 'eval') return words.slice(index + 1).map(({ value }) => value).join(' ')
|
|
357
|
-
return isShell(word) ? shellCommandScript(words, index) : undefined
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** A shell at the rule start with no script argument reads its script from stdin: a pipe, a here-document. */
|
|
361
|
-
const readsScriptFromInput = (words: SimpleCommand): boolean => {
|
|
362
|
-
const start = ruleStart(words)
|
|
363
|
-
const shell = words[start]
|
|
364
|
-
return shell !== undefined && isShell(shell) && words.slice(start + 1).every(isOption)
|
|
365
|
-
}
|
|
366
|
-
|
|
367
298
|
/**
|
|
368
299
|
* `HUSKY=0`, `export HUSKY=0`, `local -x HUSKY=0`, `set -a; HUSKY=0`: wherever such an assignment
|
|
369
300
|
* stands, it may reach a later git call of the script, not only the one it precedes.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the user approved, read from the session transcript. The transcript is written by Claude Code,
|
|
3
|
+
* not by the agent: a dialog answer sits in it as the tool result of an `AskUserQuestion` call, with
|
|
4
|
+
* the question the agent put and the label the user picked. Nothing an agent says in a message,
|
|
5
|
+
* a file or a command counts, so a text cannot stand in for the click.
|
|
6
|
+
*
|
|
7
|
+
* The limit: an agent that runs as the user could still write to the transcript file. This guards
|
|
8
|
+
* against a mistake, not against an agent that sets out to get around it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
type Record_ = Record<string, unknown>
|
|
12
|
+
|
|
13
|
+
const MS_PER_MINUTE = 60_000
|
|
14
|
+
const BRANCH_MENTION = /\bBranch `([^`]*)`/g
|
|
15
|
+
|
|
16
|
+
/** A branch name as git takes it from a shell word: no leading dash, no space, no `..`, no shell character. */
|
|
17
|
+
const BRANCH_NAME = /^(?![-/.])(?!.*(?:\.\.|\/\/|@\{))[A-Za-z0-9._/-]+$/
|
|
18
|
+
|
|
19
|
+
const isRecord = (value: unknown): value is Record_ => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
20
|
+
|
|
21
|
+
const contentOf = (entry: Record_): Record_[] => {
|
|
22
|
+
const content = isRecord(entry.message) ? entry.message.content : undefined
|
|
23
|
+
return Array.isArray(content) ? content.filter(isRecord) : []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const isMainThread = (entry: Record_): boolean => entry.isSidechain !== true
|
|
27
|
+
|
|
28
|
+
/** One JSON value per line; a line that is not JSON is skipped. */
|
|
29
|
+
export const parseTranscript = (text: string): unknown[] =>
|
|
30
|
+
text.split('\n').flatMap((line) => {
|
|
31
|
+
try {
|
|
32
|
+
return line.trim() === '' ? [] : [JSON.parse(line)]
|
|
33
|
+
} catch {
|
|
34
|
+
return []
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** Whether the question offers `label` as one option among single choices: a multi-select joins its picks into one text. */
|
|
39
|
+
const offersLabel = (question: Record_, label: string): boolean =>
|
|
40
|
+
question.multiSelect !== true &&
|
|
41
|
+
Array.isArray(question.options) &&
|
|
42
|
+
question.options.some((option) => isRecord(option) && option.label === label)
|
|
43
|
+
|
|
44
|
+
const questionTexts = (input: unknown, label: string): string[] => {
|
|
45
|
+
if (!isRecord(input) || 'answers' in input || !Array.isArray(input.questions)) return []
|
|
46
|
+
return input.questions.flatMap((question) =>
|
|
47
|
+
isRecord(question) && typeof question.question === 'string' && offersLabel(question, label)
|
|
48
|
+
? [question.question]
|
|
49
|
+
: [],
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The questions of each `AskUserQuestion` call that offer `label`, by tool use id. A call that carries its own
|
|
55
|
+
* `answers` asked nobody.
|
|
56
|
+
*/
|
|
57
|
+
const askedQuestions = (entries: Record_[], label: string): Map<string, string[]> =>
|
|
58
|
+
new Map(
|
|
59
|
+
entries
|
|
60
|
+
.filter(isMainThread)
|
|
61
|
+
.flatMap(contentOf)
|
|
62
|
+
.filter((block) => block.type === 'tool_use' && block.name === 'AskUserQuestion' && typeof block.id === 'string')
|
|
63
|
+
.map((block): [string, string[]] => [block.id as string, questionTexts(block.input, label)]),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const isFresh = (timestamp: unknown, now: number, windowMinutes: number): boolean => {
|
|
67
|
+
const answeredAt = typeof timestamp === 'string' ? Date.parse(timestamp) : Number.NaN
|
|
68
|
+
const age = now - answeredAt
|
|
69
|
+
return age >= 0 && age <= windowMinutes * MS_PER_MINUTE
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The one branch a question names as ``Branch `name` ``; a question that names none or several approves none. */
|
|
73
|
+
const namedBranch = (question: string): string | undefined => {
|
|
74
|
+
const names = new Set([...question.matchAll(BRANCH_MENTION)].map(([, name]) => name))
|
|
75
|
+
const [only] = names
|
|
76
|
+
return names.size === 1 && BRANCH_NAME.test(only) ? only : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The branches one result entry approves: the questions of its `AskUserQuestion` call that the user answered with `label`. */
|
|
80
|
+
const branchesApprovedIn = (entry: Record_, asked: Map<string, string[]>, label: string): string[] => {
|
|
81
|
+
const answers = isRecord(entry.toolUseResult) ? entry.toolUseResult.answers : undefined
|
|
82
|
+
if (!isRecord(answers)) return []
|
|
83
|
+
return contentOf(entry)
|
|
84
|
+
.filter((block) => block.type === 'tool_result')
|
|
85
|
+
.flatMap((block) => asked.get(String(block.tool_use_id)) ?? [])
|
|
86
|
+
.filter((question) => answers[question] === label)
|
|
87
|
+
.flatMap((question) => namedBranch(question) ?? [])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type ApprovalQuery = {
|
|
91
|
+
entries: unknown[]
|
|
92
|
+
/** The exact option label that approves a merge. */
|
|
93
|
+
label: string
|
|
94
|
+
/** The time of the check, in milliseconds. */
|
|
95
|
+
now: number
|
|
96
|
+
/** How long an answer holds, in minutes; an answer from the future does not hold at all. */
|
|
97
|
+
windowMinutes: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The branches whose question the user answered with `label` within the window. */
|
|
101
|
+
export const approvedBranches = ({ entries, label, now, windowMinutes }: ApprovalQuery): Set<string> => {
|
|
102
|
+
if (label === '') return new Set()
|
|
103
|
+
const records = entries.filter(isRecord)
|
|
104
|
+
const asked = askedQuestions(records, label)
|
|
105
|
+
const fresh = records.filter((entry) => isMainThread(entry) && isFresh(entry.timestamp, now, windowMinutes))
|
|
106
|
+
return new Set(fresh.flatMap((entry) => branchesApprovedIn(entry, asked, label)))
|
|
107
|
+
}
|