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.
Files changed (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +36 -7
  3. package/dist/{chunk-R6MGHCD7.js → chunk-UI3AZNZF.js} +5 -0
  4. package/dist/config.d.ts +7 -0
  5. package/dist/config.js +17 -1
  6. package/dist/plan-format.d.ts +7 -1
  7. package/dist/plan-format.js +3 -1
  8. package/herdr-plugin/herdr-plugin.toml +1 -1
  9. package/install.sh +1 -1
  10. package/kit/hooks/hooks/git-guard/decision.ts +10 -79
  11. package/kit/hooks/hooks/merge-guard/approval.ts +107 -0
  12. package/kit/hooks/hooks/merge-guard/decision.ts +286 -0
  13. package/kit/hooks/hooks/merge-guard/index.ts +56 -0
  14. package/kit/hooks/hooks/merge-guard/pull-request.ts +33 -0
  15. package/kit/hooks/hooks/merge-guard/run.ts +60 -0
  16. package/kit/hooks/hooks/merge-guard/settings.ts +56 -0
  17. package/kit/hooks/hooks/merge-guard/worker.ts +14 -0
  18. package/kit/hooks/hooks/shared/program-position.ts +82 -0
  19. package/kit/hooks/hooks/watch-guard/shell-input.ts +1 -1
  20. package/kit/hooks/hooks/watch-guard/starts.ts +1 -1
  21. package/kit/hooks/hooks.json +10 -0
  22. package/kit/workflow/skills/orchestrate/SKILL.md +68 -5
  23. package/package.json +1 -1
  24. package/schemas/jackherd.schema.json +35 -0
  25. package/src/config/schema.ts +23 -0
  26. package/src/sync/command-context.ts +8 -4
  27. package/src/sync/command.ts +27 -9
  28. package/src/sync/if-changed.ts +18 -8
  29. package/src/sync/render.ts +4 -2
  30. package/src/sync/report.ts +21 -0
  31. package/src/sync/settings-migration.ts +45 -3
  32. package/src/sync/settings-offer.ts +8 -2
  33. package/src/sync/sync.ts +7 -3
  34. package/src/vocabulary/index.ts +10 -0
  35. package/src/watch/command.ts +7 -1
  36. package/src/watch/report-questions.ts +16 -2
  37. package/src/watch/update-available.ts +26 -0
  38. /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
+ [![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)
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.1.0 is on npm, 1.0.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.
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-R6MGHCD7.js";
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
  });
@@ -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 };
@@ -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-R6MGHCD7.js";
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,
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.1.0"
3
+ version = "1.3.0"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the plans 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.1.0}"
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 { parseShell, ShellParseError, type SimpleCommand, type Word } from './shell'
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
+ }