jackherd 1.7.0 → 1.8.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 (157) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/INSTALL.md +4 -2
  3. package/{NOTICE → NOTICE.txt} +1 -1
  4. package/README.md +94 -1033
  5. package/assets/icons/bark-dark.svg +1 -1
  6. package/assets/icons/bark.svg +1 -1
  7. package/assets/icons/bowl-dark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide-dark.svg +1 -1
  10. package/assets/icons/decide.svg +1 -1
  11. package/assets/icons/ear-dark.svg +1 -1
  12. package/assets/icons/ear.svg +1 -1
  13. package/assets/icons/lanes-dark.svg +1 -1
  14. package/assets/icons/lanes.svg +1 -1
  15. package/assets/icons/plan-dark.svg +1 -1
  16. package/assets/icons/plan.svg +1 -1
  17. package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
  18. package/dist/config.d.ts +11 -5
  19. package/dist/config.js +53 -21
  20. package/dist/plan-format.d.ts +73 -7
  21. package/dist/plan-format.js +180 -100
  22. package/docs/workflow-dark.svg +1 -1
  23. package/docs/workflow.svg +1 -1
  24. package/herdr-plugin/herdr-plugin.toml +1 -1
  25. package/install.sh +1 -1
  26. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
  27. package/kit/hooks/hooks/approval-gate/index.ts +10 -8
  28. package/kit/hooks/hooks/approval-gate/run.ts +21 -12
  29. package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
  30. package/kit/hooks/hooks/git-guard/decision.ts +189 -15
  31. package/kit/hooks/hooks/git-guard/index.ts +13 -3
  32. package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
  33. package/kit/hooks/hooks/git-guard/repository.ts +63 -0
  34. package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
  35. package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
  36. package/kit/hooks/hooks/merge-guard/index.ts +6 -4
  37. package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
  38. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
  39. package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
  40. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
  41. package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
  42. package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
  43. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
  44. package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
  45. package/kit/hooks/hooks/merge-guard/run.ts +118 -29
  46. package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
  47. package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
  48. package/kit/hooks/hooks/shared/base-branch.ts +23 -0
  49. package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
  50. package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
  51. package/kit/hooks/hooks/shared/command.ts +14 -2
  52. package/kit/hooks/hooks/shared/commit.ts +2 -0
  53. package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
  54. package/kit/hooks/hooks/shared/json.ts +10 -0
  55. package/kit/hooks/hooks/shared/lane.ts +57 -0
  56. package/kit/hooks/hooks/shared/once.ts +8 -0
  57. package/kit/hooks/hooks/shared/own-pane.ts +31 -0
  58. package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
  59. package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
  60. package/kit/hooks/hooks/shared/program-position.ts +148 -5
  61. package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
  62. package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
  63. package/kit/hooks/hooks/shared/shell.ts +167 -20
  64. package/kit/hooks/hooks.json +11 -2
  65. package/kit/hooks/statusline.sh +29 -16
  66. package/kit/review/skills/diagnose/SKILL.md +1 -1
  67. package/kit/review/skills/review/SKILL.md +2 -0
  68. package/kit/rules/rules/kit/testing.md +2 -5
  69. package/kit/rules/rules/kit/writing-for-agents.md +3 -1
  70. package/kit/workflow/agents/spec-review.md +1 -1
  71. package/kit/workflow/skills/follow/SKILL.md +3 -3
  72. package/kit/workflow/skills/grill/SKILL.md +1 -1
  73. package/kit/workflow/skills/implement/SKILL.md +10 -11
  74. package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
  75. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
  76. package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
  77. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
  78. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
  79. package/kit/workflow/skills/plan/SKILL.md +5 -2
  80. package/kit/workflow/skills/plan/references/template-English.md +6 -0
  81. package/kit/workflow/skills/plan/references/template-German.md +6 -0
  82. package/package.json +3 -2
  83. package/recipes/README.md +21 -0
  84. package/recipes/github-issues.md +23 -0
  85. package/recipes/github-projects.md +22 -0
  86. package/recipes/jira-cli.md +26 -0
  87. package/recipes/jira-rest.md +24 -0
  88. package/recipes/linear.md +25 -0
  89. package/recipes/sentry.md +18 -0
  90. package/recipes/sources/jira.ts +51 -0
  91. package/recipes/sources/linear.ts +19 -0
  92. package/recipes/sources/sentry.ts +12 -0
  93. package/recipes/sources.md +80 -0
  94. package/recipes/status-page.md +19 -0
  95. package/schemas/jackherd.schema.json +42 -18
  96. package/src/check/command.ts +2 -2
  97. package/src/check/parallel-report.ts +19 -9
  98. package/src/cli.ts +1 -0
  99. package/src/config/context-shares.ts +130 -0
  100. package/src/config/legacy-names.ts +8 -0
  101. package/src/config/read-config.ts +12 -4
  102. package/src/config/schema.ts +75 -20
  103. package/src/context/auto-compact.ts +68 -0
  104. package/src/context/command.ts +68 -17
  105. package/src/context/coordinator-name.ts +27 -9
  106. package/src/context/index.ts +0 -1
  107. package/src/context/level.ts +18 -11
  108. package/src/context/report.ts +6 -3
  109. package/src/plan-format/dependencies.ts +144 -32
  110. package/src/plan-format/index.ts +6 -0
  111. package/src/plan-format/lanes.ts +6 -0
  112. package/src/plan-format/progress.ts +19 -8
  113. package/src/plans/command.ts +14 -5
  114. package/src/plans/current-phase.ts +63 -7
  115. package/src/plans/current-task.ts +51 -0
  116. package/src/plans/ensure-panes.ts +25 -8
  117. package/src/plans/frame.ts +99 -0
  118. package/src/plans/git.ts +8 -0
  119. package/src/plans/herd-memory.ts +107 -0
  120. package/src/plans/lane-plans.ts +5 -3
  121. package/src/plans/lane-summary.ts +222 -0
  122. package/src/plans/layout-phase.ts +148 -0
  123. package/src/plans/layout-summary.ts +140 -0
  124. package/src/plans/layout.ts +164 -420
  125. package/src/plans/live.ts +11 -6
  126. package/src/plans/plan-panes.ts +15 -0
  127. package/src/plans/planned.ts +9 -42
  128. package/src/plans/selection.ts +2 -6
  129. package/src/plans/settings.ts +2 -7
  130. package/src/plans/snapshot.ts +38 -39
  131. package/src/plans/symbols.ts +2 -2
  132. package/src/plans/texts.ts +32 -31
  133. package/src/plans/view.ts +0 -1
  134. package/src/plans/worked-phases.ts +2 -2
  135. package/src/settled/board-row.ts +133 -0
  136. package/src/settled/check.ts +129 -0
  137. package/src/settled/command.ts +65 -0
  138. package/src/settled/evidence-version.ts +14 -0
  139. package/src/settled/options.ts +69 -0
  140. package/src/settled/send.ts +27 -0
  141. package/src/settled/settle.ts +87 -0
  142. package/src/settled/topics.ts +56 -0
  143. package/src/sync/command.ts +16 -1
  144. package/src/sync/render.ts +13 -4
  145. package/src/sync/sync.ts +25 -5
  146. package/src/terminal/herdr.ts +46 -10
  147. package/src/terminal/none.ts +1 -0
  148. package/src/terminal/types.ts +15 -2
  149. package/src/update/command.ts +4 -2
  150. package/src/update/update.ts +33 -9
  151. package/src/vocabulary/index.ts +47 -11
  152. package/src/watch/appointments.ts +205 -0
  153. package/src/watch/command.ts +62 -19
  154. package/src/watch/lane-report.ts +11 -3
  155. package/src/watch/texts.ts +35 -59
  156. package/src/watch/watch-events.ts +21 -28
  157. package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
package/docs/workflow.svg CHANGED
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="960" height="708" viewBox="0 0 960 708" role="img" aria-labelledby="t d"><title id="t">jackherd in herdr</title><desc id="d">Animated schematic of one herdr window, told in five numbered steps. 1, Lane search works: its state is a teal dot and its progress counts up. 2, It hits an open question: search turns to an amber diamond and sends the question as text. 3, The coordinator asks you: the question appears in the coordinator pane as a choice. 4, You pick: option 1 is selected. 5, The lane works on: the answer goes back to search, which is a teal dot again, and the herd counts one more box.</desc><defs><style>.s{font-family:ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif}.m{font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace}@keyframes k1{0%{opacity:1.0}0%{opacity:1.0}38.12%{opacity:1.0}40%{opacity:0.42}80%{opacity:0.42}81.88%{opacity:1.0}100%{opacity:1.0}}.k1{animation:k1 16s linear infinite both}@keyframes k2{0%{opacity:0.42}40%{opacity:0.42}41.88%{opacity:1.0}81.88%{opacity:1.0}83.75%{opacity:0.42}100%{opacity:0.42}}.k2{animation:k2 16s linear infinite both}@keyframes k3{0%{opacity:0.42}100%{opacity:0.42}}.k3{animation:k3 16s linear infinite both}@keyframes k4{0%{opacity:0}21.25%{opacity:0}23.75%{opacity:1}82.5%{opacity:1}85%{opacity:0}100%{opacity:0}}.k4{animation:k4 16s linear infinite both}@keyframes k5{0%{opacity:0;transform:translate(64px,198px)}23.75%{opacity:0;transform:translate(64px,198px)}23.94%{opacity:1;transform:translate(64px,198px)}24.32%{opacity:1;transform:translate(60.4px,197.5px)}24.9%{opacity:1;transform:translate(57.1px,196.1px)}25.47%{opacity:1;transform:translate(54.2px,193.9px)}26.04%{opacity:1;transform:translate(51.6px,191.1px)}26.61%{opacity:1;transform:translate(49.4px,187.6px)}27.19%{opacity:1;transform:translate(47.6px,183.8px)}27.76%{opacity:1;transform:translate(46.3px,179.6px)}28.33%{opacity:1;transform:translate(45.4px,175.2px)}28.91%{opacity:1;transform:translate(45px,170.7px)}29.48%{opacity:1;transform:translate(45.1px,166.3px)}30.05%{opacity:1;transform:translate(45.8px,162px)}30.63%{opacity:0;transform:translate(47px,158px)}100%{opacity:0;transform:translate(47px,158px)}}.k5{animation:k5 16s linear infinite both}@keyframes k6{0%{opacity:0;transform:translate(47px,158px)}81.25%{opacity:0;transform:translate(47px,158px)}81.44%{opacity:1;transform:translate(47px,158px)}81.67%{opacity:1;transform:translate(45.8px,162px)}82.08%{opacity:1;transform:translate(45.1px,166.3px)}82.5%{opacity:1;transform:translate(45px,170.7px)}82.92%{opacity:1;transform:translate(45.4px,175.2px)}83.33%{opacity:1;transform:translate(46.3px,179.6px)}83.75%{opacity:1;transform:translate(47.6px,183.8px)}84.17%{opacity:1;transform:translate(49.4px,187.6px)}84.58%{opacity:1;transform:translate(51.6px,191.1px)}85%{opacity:1;transform:translate(54.2px,193.9px)}85.42%{opacity:1;transform:translate(57.1px,196.1px)}85.83%{opacity:1;transform:translate(60.4px,197.5px)}86.25%{opacity:0;transform:translate(64px,198px)}100%{opacity:0;transform:translate(64px,198px)}}.k6{animation:k6 16s linear infinite both}@keyframes k7{0%{opacity:0}31.25%{opacity:0}33.75%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k7{animation:k7 16s linear infinite both}@keyframes k8{0%{opacity:0}41.25%{opacity:0}43.75%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k8{animation:k8 16s linear infinite both}@keyframes k9{0%{opacity:0}63.75%{opacity:0}66.25%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k9{animation:k9 16s linear infinite both}@keyframes k10{0%{opacity:0}68.75%{opacity:0}71.25%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k10{animation:k10 16s linear infinite both}@keyframes k11{0%{opacity:0}81.25%{opacity:0}83.75%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k11{animation:k11 16s linear infinite both}@keyframes k12{0%{opacity:1}21.25%{opacity:1}23.75%{opacity:0}82.5%{opacity:0}85%{opacity:1}100%{opacity:1}}.k12{animation:k12 16s linear infinite both}@keyframes k13{0%{opacity:0}8.75%{opacity:0}10.62%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k13{animation:k13 16s linear infinite both}@keyframes k14{0%{opacity:0}88.75%{opacity:0}90.62%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k14{animation:k14 16s linear infinite both}@keyframes k15{0%{opacity:1}8.75%{opacity:1}10.62%{opacity:0}96.25%{opacity:0}100%{opacity:1}}.k15{animation:k15 16s linear infinite both}@keyframes k16{0%{opacity:0}8.75%{opacity:0}10.62%{opacity:1}88.75%{opacity:1}90.62%{opacity:0}100%{opacity:0}}.k16{animation:k16 16s linear infinite both}@keyframes k17{0%{opacity:0}88.75%{opacity:0}90.62%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k17{animation:k17 16s linear infinite both}@keyframes k18{0%{opacity:1}8.75%{opacity:1}10.62%{opacity:0}96.25%{opacity:0}100%{opacity:1}}.k18{animation:k18 16s linear infinite both}@keyframes k19{0%{opacity:0}8.75%{opacity:0}10.62%{opacity:1}88.75%{opacity:1}90.62%{opacity:0}100%{opacity:0}}.k19{animation:k19 16s linear infinite both}@keyframes k20{0%{opacity:0}88.75%{opacity:0}90.62%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k20{animation:k20 16s linear infinite both}@keyframes k21{0%{opacity:1}18.13%{opacity:1}20%{opacity:0}100%{opacity:0}}.k21{animation:k21 16s linear infinite both}@keyframes k22{0%{opacity:1}18.13%{opacity:1}20%{opacity:0.62}100%{opacity:0.62}}.k22{animation:k22 16s linear infinite both}@keyframes k23{0%{opacity:0}20%{opacity:0}21.88%{opacity:1}38.12%{opacity:1}40%{opacity:0}100%{opacity:0}}.k23{animation:k23 16s linear infinite both}@keyframes k24{0%{opacity:0.62}20%{opacity:0.62}21.88%{opacity:1.0}38.12%{opacity:1.0}40%{opacity:0.62}100%{opacity:0.62}}.k24{animation:k24 16s linear infinite both}@keyframes k25{0%{opacity:0}40%{opacity:0}41.88%{opacity:1}58.12%{opacity:1}60%{opacity:0}100%{opacity:0}}.k25{animation:k25 16s linear infinite both}@keyframes k26{0%{opacity:0.62}40%{opacity:0.62}41.88%{opacity:1.0}58.12%{opacity:1.0}60%{opacity:0.62}100%{opacity:0.62}}.k26{animation:k26 16s linear infinite both}@keyframes k27{0%{opacity:0}60%{opacity:0}61.88%{opacity:1}78.12%{opacity:1}80%{opacity:0}100%{opacity:0}}.k27{animation:k27 16s linear infinite both}@keyframes k28{0%{opacity:0.62}60%{opacity:0.62}61.88%{opacity:1.0}78.12%{opacity:1.0}80%{opacity:0.62}100%{opacity:0.62}}.k28{animation:k28 16s linear infinite both}@keyframes k29{0%{opacity:0}80%{opacity:0}81.88%{opacity:1}98.12%{opacity:1}100%{opacity:0}}.k29{animation:k29 16s linear infinite both}@keyframes k30{0%{opacity:0.62}80%{opacity:0.62}81.88%{opacity:1}98.12%{opacity:1}100%{opacity:0.62}}.k30{animation:k30 16s linear infinite both}@media (prefers-reduced-motion:reduce){*{animation:none!important}}</style><clipPath id="jack-clip"><rect width="240" height="240" rx="50"/></clipPath><symbol id="jack" viewBox="0 0 240 240"><rect width="240" height="240" rx="50" fill="#212E34"/><g clip-path="url(#jack-clip)"><g transform="translate(34 8)"><path fill="#4A4E51" d="M80 50.5c.2 .1 .2 .1 1 1c-.2 .6-.3 1.3-.5 2c-.7 .4-.7 .4-4 2c-6 3.3-6 3.3-7 6.5c1.2-.4 2.3-.6 3.5-1c.8 0 1.7 0 2.5 0c.2 .1 .2 .1 1 1c-.5 2-.5 2-5.5 6.5c-3 5-3 5-5.5 11c.2 .3 .2 .3 1 2c.2 .3 .3 .7 .5 1c-.7 .7-1.3 1.3-2 2c-1.3-.2-2.6-.3-4-.5c0-1.2 0-2.3 0-3.5c-.2 .1-.2 .1-1.5 .5c-1.2 2.7-2.4 5.3-3.5 8c-1 2.1-2 4.2-3 6.3c-2.5 5.7-2.5 5.7-3 8.2c.5 0 1 0 1.5 0c.1-.8 .4-1.7 .5-2.5c.6 0 1.3 0 2 0c.5 2 1 3.9 1.6 6c1 4 2.2 8 3.4 11.9c2.1 8.4 1.1 15.5-3 23.1c-.6-.2-1.3-.3-2-.5c-.1-1.5-.3-3-.5-4.6c-.3-2.6-.5-5.2-1-7.9c-.5 0-1 0-1.5 0c-.1 1.7-.4 3.3-.5 5c-2.7 10.2-2.7 10.2-6 13.5c-6.1-3.1-9-14.9-11-21c-1.1-8.2 .9-14.7 3.5-22.5c-8.4 8.3-8.3 20.5-9 31.5c-.6-.2-1.3-.3-2-.5c-5.4-8.9-5.1-15-3-25c.5-1 1-2 1.5-3c-3 1.5-3 1.5-6.5 5c-.4 0-.6 0-1 0c.8-6.5 3.2-10.2 7.5-15c.6-.8 .6-.8 3.5-5c2.6-2.6 5.2-5.1 8-7.5c.6-.6 .6-.6 3.5-3.5c-.5-2-.5-2-1-3.5c3.6-6 6.5-9.2 12.5-13c.4-.6 .6-1.3 1-2c-.5-.6-1-1.3-1.5-2c2-3 2-3 4.5-4.5c.5 1 .5 1 1 3.5c2.5-1 2.5-1 8.5-5.5c.3 0 .7 0 1 0c-.1-.4-.1-.4-.5-2.5c4.3-3.3 10-2.5 15-1zM153 69.5c1.2 .2 2.3 .3 3.5 .5c.3 .7 .7 1.3 1 2c.3 0 .7 0 1 0c.5 1.2 1 2.3 1.5 3.5c3.4 7.6 6.2 13.7 12.2 19.6c3.3 3.4 3.3 3.4 3.8 5.9c-.2 .2-.3 .3-.5 .5c.1 .7 .1 .7 .5 4.5c-.5 .3-1 .7-1.5 1c-.8-.3-1.7-.7-2.5-1c.3 .7 .7 1.3 1 2c1.2 5.8 1.4 10 1 16c-.7 0-1.3 0-2 0c-.4-1.3-.8-2.7-1.3-4.1c-2.1-6.1-5.2-11.4-8.7-16.9c-.2-.8-.2-.8-1.5-5c-.3-.6-.3-.6-2.1-3.5c-1.9-3.5-1.9-3.5-2.9-8.5c.7 0 1.3 0 2 0c.5 .5 1 1 1.5 1.5c-.8-2.3-1.7-4.6-2.5-7c-.7-2-1.3-4-2-6c-.7-1-1.3-2-2-3c.2-.7 .3-1.3 .5-2zM86.5 100.5c1 0 2 0 3 0c1.3 .8 2.7 1.7 4 2.5c-2.5 2.5-2.5 2.5-8 5.5c-5.5 4.3-8.6 7-10 14c-.8 2.2-1.7 4.3-2.5 6.5c-1.2 4.6-.5 9.3 0 14c-.5 0-1 0-1.5 0c-.2 2-.3 3.9-.5 6c-.7 0-1.3 0-2 0c-.3-.6-.3-.6-2-3.5c-1-.8-2-1.7-3-2.5c-.5-1.5-.5-1.5 1.5-8.5c.3-10.3-1-18.2-4-28c0-4 0-4 1-6c2 2 2 2 3 6.5c2.5-.5 2.5-.5 8-4c9.8-3.5 9.8-3.5 13-2.5zM43 161.5c2.5 2.5 2.5 2.5 3 5c-1.5 2.5-1.5 2.5-4 5c.5 3.5 .5 3.5 4 6.5c-.1 1.2-.4 2.3-.5 3.5c-5.3 3.5-5.3 3.5-8.5 3.5c-6-2.5-6-2.5-11.5-7c0-3 0-3 4-8c9.5-8.5 9.5-8.5 13.5-8.5z"/><path fill="#9B9D9E" d="M142.5 60.5c.5 1 .5 1-.5 4.5c1.2 1.5 2.3 3 3.5 4.5c2 4.5 2 4.5 2.5 9.5c-.5 .2-1 .3-1.5 .5c-.1 .2-.1 .2-.5 1c-2-1-3.9-2-6-3c-.8-.3-1.7-.7-2.5-1c-.3-.7-.7-1.3-1-2c.3-.5 .7-1 1-1.5c-.2-.7-.3-1.3-.5-2c-3.5 .5-3.5 .5-7.5 4c-.5 3-.5 3 0 5.5c-2 4-2 4-3.5 5.5c-.3 0-.7 0-1 0c0 .7 0 1.3 0 2c-2-1-2-1-2.5-2.5c.3-13.7 .3-13.7 4.8-18.5c4.1-3.9 6.3-4.8 11.7-6c1-.5 1-.5 3.5-.5zM98 70.5c.5 .3 1 .7 1.5 1c1.3 .3 2.7 .7 4 1c4.4 5.8 6.5 10.3 6.5 17.5c-2 2-2 2-5 2.5c-.3 .2-.3 .2-2 1c-.2-.2-.2-.2-1.5-1c.5-3 .5-3 1-5c-.7-.8-1.3-1.7-2-2.5c-.2-1.7-.3-3.3-.5-5c-4.5-2.7-8.5-3-13.5-2.5c-.1 .3-.1 .3-.5 1.5c1.3 .8 2.7 1.7 4 2.5c-.1 .4-.1 .4-.5 2.5c-2.3 1.2-4.6 2.3-7 3.5c-5.5 5.5-5.5 5.5-6.5 7c0 .5 0 1 0 1.5c.9-.1 .9-.1 5.5-.5c1 1.3 2 2.7 3 4c-.2 .5-.3 1-.5 1.5c-1.7 .5-3.2 1-5 1.6c-5.5 1.9-5.5 1.9-11 5.4c-.4 .1-.4 .1-2.5 .5c-1.5-1.5-1.5-1.5-2.5-6c-.4-.3-.6-.7-1-1c0-6.7 .9-11.7 3-18c.2-.6 .2-.6 1-3.5c7.8-9.7 20.5-14.1 32-9.5z"/><path fill="#FAF9F8" d="M103.5 40c0 .4 0 .6 0 1c2.2-.1 4.3-.4 6.5-.5c7 0 7 0 9.5 1c-.2 .6-.3 1.3-.5 2c-1.8 .6-3.7 1.3-5.5 2c-.2 .5-.3 1-.5 1.5c.7-.1 1.3-.4 2-.5c13.4-.3 13.4-.3 19.5 3.5c-.3 .6-.7 1.3-1 2c-1 0-2 0-3 0c0 .5 0 1 0 1.5c1.2 .1 1.2 .1 7 .5c3.5 2 3.5 2 5.5 4.5c0 1 0 2 0 3c-3.3 .9-6.7 1.6-10 2.5c-7.5 5-7.5 5-8.5 9c-.3 4.5-.4 9-.5 13.5c1.5 1 1.5 1 7.5 2.5c.9 .6 .9 .6 5.5 3.5c2 1 2 1 9 2.5c7.9 4 11.3 8.3 15 16.5c0 .5 0 1 0 1.5c-1 .2-2 .3-3 .5c.5 1.8 1 3.7 1.5 5.5c.3 8.5-.8 13.6-6 20.5c-.7-.2-1.3-.3-2-.5c0-1.7 0-3.3 0-5c-.7 .2-1.3 .3-2 .5c-1.3 2.7-2.7 5.3-4 8c-3.2 3.7-6.4 6.2-11 8c-1-1-1-1-1.5-3c-2.5 1.3-4.8 2.9-7 4.5c-6.3 2.8-10.8 3.2-17.6 1c-1.3-.5-2.6-1-3.9-1.5c.2 1.2 .3 2.3 .5 3.5c-.2 .3-.3 .7-.5 1c-5.9 0-9-1.8-14-5c-3-3.5-3-3.5-3-4.5c-.7 0-1.3 0-2 0c.5 2.2 1 4.3 1.5 6.5c-.3 .3-.7 .7-1 1c-4.5-.9-7.6-3.6-11-6.5c-4-6-4-9.3-4-16.5c2.5-7.5 2.5-7.5 3.5-9.5c.3-1.7 .7-3.3 1-5c6.4-8 14.7-13 24.5-16c6.4-6.3 6.4-6.3 8.5-10.5c0-6-1.6-9.8-4.5-15c-.2-.5-.3-1-.5-1.5c-1.7-.3-3.3-.7-5-1c-1.3-.7-2.7-1.3-4-2c-5.7-1.9-10.2-.3-15.5 1.5c-10 5.5-10 5.5-13 10c-.3-.2-.3-.2-2-1c1.3-6.8 4.6-11.7 9.5-16.5c.5-.3 1-.6 1.5-1c.2-.5 .3-1 .5-1.5c-1 .1-1 .1-6 1c-.3-.4-.7-.6-1-1c.5-2 .5-2 4-6c5-2.5 5-2.5 7.5-3c0-.5 0-1 0-1.5c-10.5-1.5-18.5 1-27 7c-.5 .9-1 1.6-1.5 2.5c-.9 .6-.9 .6-5.5 4c-4 3.5-7.6 7-11 11c-4.6 3.8-8.7 6.8-14 9.5c-.7-.3-1.3-.7-2-1c.2-.7 .3-1.3 .5-2c1.8-1 3.7-2 5.5-3c5.2-4.2 9.7-8.5 14-13.5c3.1-2.8 6.2-5.5 9.5-8c1.3-1.3 2.6-2.6 4-4c5.6-3.6 10.4-6.1 17-7c.5-1 1-2 1.5-3c7.2-2.4 12.5-2.2 20-.5c.2 .1 .2 .1 1 .5c3.5 0 3.5 0 8.5-3.5c1-.5 1-.5 3-.5zM118 92.5c-1 1.2-2 2.3-3 3.5c.5 2.5 .5 2.5 3 5c1.8 .8 3.7 1.7 5.5 2.5c.5 1 .5 1 .5 4.5c-4 4.8-6.1 5-12.5 6c0 .7 0 1.3 0 2c4.7 1.5 7.7 .2 12-1.5c1.2-.7 2.3-1.3 3.5-2c1.3 .3 2.7 .7 4 1c3.5 0 3.5 0 5.5-1.5c-.2-.5-.3-1-.5-1.5c-1.7-.3-3.3-.7-5-1c-2.5-2-2.5-2-3-5.5c2-3 2-3 4-5.5c.5-3.5 .5-3.5-1.5-6.5c-4.3-2.2-8.2-1-12.5 .5zM146 58.5c5.8 1.1 8.3 3.9 12.5 8c3.7 5.9 4.4 7.3 5 14c2.8 6.2 5.4 10 10.5 14.5c5.2 5.2 7.2 10.3 9 17.5c0 3.5 0 3.5-2 11c-.2 1.3-.2 1.3-1 7.5c-3.8 10.7-3.8 10.7-8.5 13.5c-.5 .2-.5 .2-3 1c-1 1.3-2 2.7-3 4c-4.5 3-4.5 3-10.5 5c-2.7-.6-5.3-1.3-8-2c-1.7 1.2-3.3 2.3-5 3.5c-2 1-2 1-4 .5c.5-1.5 .5-1.5 6-5c.3-.3 .3-.3 2-2c2.4 0 4.9 0 7.5 0c.5 .5 1 1 1.5 1.5c4.3-.6 7.1-2.3 10.5-5c3.5-5.5 3.5-5.5 4-7c1.5 0 2.9 0 4.5 0c4.3-8.7 4.6-14 2.5-23.5c-.3-1-.7-2-1-3c.3 .1 .3 .1 2 .5c1.5 2.5 1.5 2.5 1.5 4c.5-.2 1-.3 1.5-.5c-.8-10-4.3-16-11.4-22.8c-3.7-3.8-5.5-6.9-7.6-11.7c-.7-1.5-1.5-2.9-2.3-4.4c-1.7-3.6-1.7-3.6-1.7-5.6c.3-.2 .7-.3 1-.5c-.5-3-.5-3-4.5-7c-2-1-3.9-2-6-3c-.7-.4-1.3-.6-2-1c0-.6 0-1.3 0-2zM33.5 83.5c-1 4-5.1 6.4-8 9c-8.6 8.1-14.6 17.2-15.2 29.2c.3 8.1 .3 8.1 2.2 11.8c.5 0 1 0 1.5 0c.4-2.9 .6-6 1-9c.6 0 1.3 0 2 0c.2 1.3 .4 2.7 .6 4.1c1.8 9.3 5.3 14.8 12.9 20.4c2.3 1.2 4.6 2.3 7 3.5c.7 .6 .7 .6 4.5 3.5c3.5 0 3.5 0 8.5-1.5c1 0 2 0 3 0c-1 2-1 2-2.5 3.5c-6.5 1.7-9.6 .9-16-1.5c-1.1-1-2.3-2-3.5-3c-1.5-.7-2.9-1.3-4.5-2c-3.9-3.4-6.9-6.5-9.5-11c-1.1-.5-2.3-1-3.5-1.5c-5.9-6.7-8-12.7-8-21.5c2.4-12.2 7.2-19.8 16.5-28c4.5-4 4.5-4 6-5.5c3.5-1 3.5-1 5-.5zM50.5 172c2.5 1 2.5 1 7 6.5c6 6.2 12 11.8 18.8 17.1c5.7 4.5 11.3 9.1 16.7 13.9c1.3 1.2 2.7 2.3 4 3.5c.7 .7 1.3 1.3 2 2c-.2 .8-.3 1.7-.5 2.5c-6 2.9-11.7 4-18.5 4c-2.6-2.6-4.3-5.5-5.5-9c-.3 .1-.3 .1-1.5 .5c0 2.3 0 4.6 0 7c-.3 .5-.7 1-1 1.5c-13.3 0-24.6-4.6-34.5-13.5c-4.5-4.5-4.5-4.5-8.5-11c.5-2.5 .5-2.5 4.5-7c5-3 5-3 8-3.5c.5 .3 1 .7 1.5 1c2-.5 2-.5 4-2c.9-4.2 1.7-8.3 2.5-12.5c.4-.3 .6-.7 1-1z"/><path fill="#03B5BD" d="M58.5 149.5c2.5 .5 2.5 .5 7 3.5c9.8 5.2 18.9 7.9 30 8.5c2.2 .5 4.3 1 6.5 1.5c7 0 7 0 9-.5c2.3-.2 4.6-.3 7-.5c3.2-.9 6.3-1.9 9.5-3c1.3-.2 2.7-.3 4-.5c.2 .2 .3 .3 .5 .5c-.8 6.3-4.1 10.2-8 15c-.2 .5-.3 1-.5 1.5c1.7-1 3.3-2 5-3c.7 .2 1.3 .3 2 .5c-3 12.2-9.3 20.3-17.5 29.5c-.7 .7-.7 .7-4 4.5c-1 .8-2 1.7-3 2.5c-3.5-.5-3.5-.5-8-5c-2.3-1.8-4.7-3.7-7-5.5c-2.7-2.2-5.3-4.3-8-6.5c-.8-.6-.8-.6-4.5-3.5c-11-9.3-20.7-18.8-28.5-31c3-4 3-4 8.5-8.5z"/><ellipse cx="94" cy="94" rx="6.5" ry="6.5" fill="#212E34"/><ellipse cx="135" cy="85.6" rx="6.2" ry="5.8" fill="#212E34"/><g fill="#FAF9F8"><circle cx="91.5" cy="91.5" r="2.1"/><circle cx="134.6" cy="84.3" r="2"/></g><ellipse cx="124" cy="93.8" rx="3.4" ry="1.1" fill="#9B9D9E" transform="rotate(-6 124 93.8)"/></g></g><rect x="5" y="5" width="230" height="230" rx="45" fill="none"/></symbol></defs>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="960" height="724" viewBox="0 0 960 724" role="img" aria-labelledby="t d"><title id="t">jackherd in herdr</title><desc id="d">Animated schematic of one herdr window, told in five numbered steps. 1, Lane search works: its state is a teal dot and its progress counts up. 2, It hits an open question: search turns to an amber diamond and sends the question as text. 3, The coordinator asks you: the question appears in the coordinator pane as a choice. 4, You pick: option 1 is selected. 5, The lane works on: the answer goes back to search, which is a teal dot again, and the herd counts one more box.</desc><defs><style>.s{font-family:ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif}.m{font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace}@keyframes k1{0%{opacity:1.0}0%{opacity:1.0}38.12%{opacity:1.0}40%{opacity:0.42}80%{opacity:0.42}81.88%{opacity:1.0}100%{opacity:1.0}}.k1{animation:k1 16s linear infinite both}@keyframes k2{0%{opacity:0.42}40%{opacity:0.42}41.88%{opacity:1.0}81.88%{opacity:1.0}83.75%{opacity:0.42}100%{opacity:0.42}}.k2{animation:k2 16s linear infinite both}@keyframes k3{0%{opacity:0.42}100%{opacity:0.42}}.k3{animation:k3 16s linear infinite both}@keyframes k4{0%{opacity:0}21.25%{opacity:0}23.75%{opacity:1}82.5%{opacity:1}85%{opacity:0}100%{opacity:0}}.k4{animation:k4 16s linear infinite both}@keyframes k5{0%{opacity:0;transform:translate(64px,198px)}23.75%{opacity:0;transform:translate(64px,198px)}23.94%{opacity:1;transform:translate(64px,198px)}24.32%{opacity:1;transform:translate(60.4px,197.5px)}24.9%{opacity:1;transform:translate(57.1px,196.1px)}25.47%{opacity:1;transform:translate(54.2px,193.9px)}26.04%{opacity:1;transform:translate(51.6px,191.1px)}26.61%{opacity:1;transform:translate(49.4px,187.6px)}27.19%{opacity:1;transform:translate(47.6px,183.8px)}27.76%{opacity:1;transform:translate(46.3px,179.6px)}28.33%{opacity:1;transform:translate(45.4px,175.2px)}28.91%{opacity:1;transform:translate(45px,170.7px)}29.48%{opacity:1;transform:translate(45.1px,166.3px)}30.05%{opacity:1;transform:translate(45.8px,162px)}30.63%{opacity:0;transform:translate(47px,158px)}100%{opacity:0;transform:translate(47px,158px)}}.k5{animation:k5 16s linear infinite both}@keyframes k6{0%{opacity:0;transform:translate(47px,158px)}81.25%{opacity:0;transform:translate(47px,158px)}81.44%{opacity:1;transform:translate(47px,158px)}81.67%{opacity:1;transform:translate(45.8px,162px)}82.08%{opacity:1;transform:translate(45.1px,166.3px)}82.5%{opacity:1;transform:translate(45px,170.7px)}82.92%{opacity:1;transform:translate(45.4px,175.2px)}83.33%{opacity:1;transform:translate(46.3px,179.6px)}83.75%{opacity:1;transform:translate(47.6px,183.8px)}84.17%{opacity:1;transform:translate(49.4px,187.6px)}84.58%{opacity:1;transform:translate(51.6px,191.1px)}85%{opacity:1;transform:translate(54.2px,193.9px)}85.42%{opacity:1;transform:translate(57.1px,196.1px)}85.83%{opacity:1;transform:translate(60.4px,197.5px)}86.25%{opacity:0;transform:translate(64px,198px)}100%{opacity:0;transform:translate(64px,198px)}}.k6{animation:k6 16s linear infinite both}@keyframes k7{0%{opacity:0}31.25%{opacity:0}33.75%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k7{animation:k7 16s linear infinite both}@keyframes k8{0%{opacity:0}41.25%{opacity:0}43.75%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k8{animation:k8 16s linear infinite both}@keyframes k9{0%{opacity:0}63.75%{opacity:0}66.25%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k9{animation:k9 16s linear infinite both}@keyframes k10{0%{opacity:0}68.75%{opacity:0}71.25%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k10{animation:k10 16s linear infinite both}@keyframes k11{0%{opacity:0}81.25%{opacity:0}83.75%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k11{animation:k11 16s linear infinite both}@keyframes k12{0%{opacity:1}21.25%{opacity:1}23.75%{opacity:0}82.5%{opacity:0}85%{opacity:1}100%{opacity:1}}.k12{animation:k12 16s linear infinite both}@keyframes k13{0%{opacity:0}8.75%{opacity:0}10.62%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k13{animation:k13 16s linear infinite both}@keyframes k14{0%{opacity:0}88.75%{opacity:0}90.62%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k14{animation:k14 16s linear infinite both}@keyframes k15{0%{opacity:1}8.75%{opacity:1}10.62%{opacity:0}96.25%{opacity:0}100%{opacity:1}}.k15{animation:k15 16s linear infinite both}@keyframes k16{0%{opacity:0}8.75%{opacity:0}10.62%{opacity:1}88.75%{opacity:1}90.62%{opacity:0}100%{opacity:0}}.k16{animation:k16 16s linear infinite both}@keyframes k17{0%{opacity:0}88.75%{opacity:0}90.62%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k17{animation:k17 16s linear infinite both}@keyframes k18{0%{opacity:1}8.75%{opacity:1}10.62%{opacity:0}96.25%{opacity:0}100%{opacity:1}}.k18{animation:k18 16s linear infinite both}@keyframes k19{0%{opacity:0}8.75%{opacity:0}10.62%{opacity:1}88.75%{opacity:1}90.62%{opacity:0}100%{opacity:0}}.k19{animation:k19 16s linear infinite both}@keyframes k20{0%{opacity:0}88.75%{opacity:0}90.62%{opacity:1}96.25%{opacity:1}100%{opacity:0}}.k20{animation:k20 16s linear infinite both}@keyframes k21{0%{opacity:1}18.13%{opacity:1}20%{opacity:0}100%{opacity:0}}.k21{animation:k21 16s linear infinite both}@keyframes k22{0%{opacity:1}18.13%{opacity:1}20%{opacity:0.62}100%{opacity:0.62}}.k22{animation:k22 16s linear infinite both}@keyframes k23{0%{opacity:0}20%{opacity:0}21.88%{opacity:1}38.12%{opacity:1}40%{opacity:0}100%{opacity:0}}.k23{animation:k23 16s linear infinite both}@keyframes k24{0%{opacity:0.62}20%{opacity:0.62}21.88%{opacity:1.0}38.12%{opacity:1.0}40%{opacity:0.62}100%{opacity:0.62}}.k24{animation:k24 16s linear infinite both}@keyframes k25{0%{opacity:0}40%{opacity:0}41.88%{opacity:1}58.12%{opacity:1}60%{opacity:0}100%{opacity:0}}.k25{animation:k25 16s linear infinite both}@keyframes k26{0%{opacity:0.62}40%{opacity:0.62}41.88%{opacity:1.0}58.12%{opacity:1.0}60%{opacity:0.62}100%{opacity:0.62}}.k26{animation:k26 16s linear infinite both}@keyframes k27{0%{opacity:0}60%{opacity:0}61.88%{opacity:1}78.12%{opacity:1}80%{opacity:0}100%{opacity:0}}.k27{animation:k27 16s linear infinite both}@keyframes k28{0%{opacity:0.62}60%{opacity:0.62}61.88%{opacity:1.0}78.12%{opacity:1.0}80%{opacity:0.62}100%{opacity:0.62}}.k28{animation:k28 16s linear infinite both}@keyframes k29{0%{opacity:0}80%{opacity:0}81.88%{opacity:1}98.12%{opacity:1}100%{opacity:0}}.k29{animation:k29 16s linear infinite both}@keyframes k30{0%{opacity:0.62}80%{opacity:0.62}81.88%{opacity:1}98.12%{opacity:1}100%{opacity:0.62}}.k30{animation:k30 16s linear infinite both}@media (prefers-reduced-motion:reduce){*{animation:none!important}}</style><clipPath id="jack-clip"><rect width="240" height="240" rx="50"/></clipPath><symbol id="jack" viewBox="0 0 240 240"><rect width="240" height="240" rx="50" fill="#212E34"/><g clip-path="url(#jack-clip)"><g transform="translate(34 8)"><path fill="#4A4E51" d="M80 50.5c.2 .1 .2 .1 1 1c-.2 .6-.3 1.3-.5 2c-.7 .4-.7 .4-4 2c-6 3.3-6 3.3-7 6.5c1.2-.4 2.3-.6 3.5-1c.8 0 1.7 0 2.5 0c.2 .1 .2 .1 1 1c-.5 2-.5 2-5.5 6.5c-3 5-3 5-5.5 11c.2 .3 .2 .3 1 2c.2 .3 .3 .7 .5 1c-.7 .7-1.3 1.3-2 2c-1.3-.2-2.6-.3-4-.5c0-1.2 0-2.3 0-3.5c-.2 .1-.2 .1-1.5 .5c-1.2 2.7-2.4 5.3-3.5 8c-1 2.1-2 4.2-3 6.3c-2.5 5.7-2.5 5.7-3 8.2c.5 0 1 0 1.5 0c.1-.8 .4-1.7 .5-2.5c.6 0 1.3 0 2 0c.5 2 1 3.9 1.6 6c1 4 2.2 8 3.4 11.9c2.1 8.4 1.1 15.5-3 23.1c-.6-.2-1.3-.3-2-.5c-.1-1.5-.3-3-.5-4.6c-.3-2.6-.5-5.2-1-7.9c-.5 0-1 0-1.5 0c-.1 1.7-.4 3.3-.5 5c-2.7 10.2-2.7 10.2-6 13.5c-6.1-3.1-9-14.9-11-21c-1.1-8.2 .9-14.7 3.5-22.5c-8.4 8.3-8.3 20.5-9 31.5c-.6-.2-1.3-.3-2-.5c-5.4-8.9-5.1-15-3-25c.5-1 1-2 1.5-3c-3 1.5-3 1.5-6.5 5c-.4 0-.6 0-1 0c.8-6.5 3.2-10.2 7.5-15c.6-.8 .6-.8 3.5-5c2.6-2.6 5.2-5.1 8-7.5c.6-.6 .6-.6 3.5-3.5c-.5-2-.5-2-1-3.5c3.6-6 6.5-9.2 12.5-13c.4-.6 .6-1.3 1-2c-.5-.6-1-1.3-1.5-2c2-3 2-3 4.5-4.5c.5 1 .5 1 1 3.5c2.5-1 2.5-1 8.5-5.5c.3 0 .7 0 1 0c-.1-.4-.1-.4-.5-2.5c4.3-3.3 10-2.5 15-1zM153 69.5c1.2 .2 2.3 .3 3.5 .5c.3 .7 .7 1.3 1 2c.3 0 .7 0 1 0c.5 1.2 1 2.3 1.5 3.5c3.4 7.6 6.2 13.7 12.2 19.6c3.3 3.4 3.3 3.4 3.8 5.9c-.2 .2-.3 .3-.5 .5c.1 .7 .1 .7 .5 4.5c-.5 .3-1 .7-1.5 1c-.8-.3-1.7-.7-2.5-1c.3 .7 .7 1.3 1 2c1.2 5.8 1.4 10 1 16c-.7 0-1.3 0-2 0c-.4-1.3-.8-2.7-1.3-4.1c-2.1-6.1-5.2-11.4-8.7-16.9c-.2-.8-.2-.8-1.5-5c-.3-.6-.3-.6-2.1-3.5c-1.9-3.5-1.9-3.5-2.9-8.5c.7 0 1.3 0 2 0c.5 .5 1 1 1.5 1.5c-.8-2.3-1.7-4.6-2.5-7c-.7-2-1.3-4-2-6c-.7-1-1.3-2-2-3c.2-.7 .3-1.3 .5-2zM86.5 100.5c1 0 2 0 3 0c1.3 .8 2.7 1.7 4 2.5c-2.5 2.5-2.5 2.5-8 5.5c-5.5 4.3-8.6 7-10 14c-.8 2.2-1.7 4.3-2.5 6.5c-1.2 4.6-.5 9.3 0 14c-.5 0-1 0-1.5 0c-.2 2-.3 3.9-.5 6c-.7 0-1.3 0-2 0c-.3-.6-.3-.6-2-3.5c-1-.8-2-1.7-3-2.5c-.5-1.5-.5-1.5 1.5-8.5c.3-10.3-1-18.2-4-28c0-4 0-4 1-6c2 2 2 2 3 6.5c2.5-.5 2.5-.5 8-4c9.8-3.5 9.8-3.5 13-2.5zM43 161.5c2.5 2.5 2.5 2.5 3 5c-1.5 2.5-1.5 2.5-4 5c.5 3.5 .5 3.5 4 6.5c-.1 1.2-.4 2.3-.5 3.5c-5.3 3.5-5.3 3.5-8.5 3.5c-6-2.5-6-2.5-11.5-7c0-3 0-3 4-8c9.5-8.5 9.5-8.5 13.5-8.5z"/><path fill="#9B9D9E" d="M142.5 60.5c.5 1 .5 1-.5 4.5c1.2 1.5 2.3 3 3.5 4.5c2 4.5 2 4.5 2.5 9.5c-.5 .2-1 .3-1.5 .5c-.1 .2-.1 .2-.5 1c-2-1-3.9-2-6-3c-.8-.3-1.7-.7-2.5-1c-.3-.7-.7-1.3-1-2c.3-.5 .7-1 1-1.5c-.2-.7-.3-1.3-.5-2c-3.5 .5-3.5 .5-7.5 4c-.5 3-.5 3 0 5.5c-2 4-2 4-3.5 5.5c-.3 0-.7 0-1 0c0 .7 0 1.3 0 2c-2-1-2-1-2.5-2.5c.3-13.7 .3-13.7 4.8-18.5c4.1-3.9 6.3-4.8 11.7-6c1-.5 1-.5 3.5-.5zM98 70.5c.5 .3 1 .7 1.5 1c1.3 .3 2.7 .7 4 1c4.4 5.8 6.5 10.3 6.5 17.5c-2 2-2 2-5 2.5c-.3 .2-.3 .2-2 1c-.2-.2-.2-.2-1.5-1c.5-3 .5-3 1-5c-.7-.8-1.3-1.7-2-2.5c-.2-1.7-.3-3.3-.5-5c-4.5-2.7-8.5-3-13.5-2.5c-.1 .3-.1 .3-.5 1.5c1.3 .8 2.7 1.7 4 2.5c-.1 .4-.1 .4-.5 2.5c-2.3 1.2-4.6 2.3-7 3.5c-5.5 5.5-5.5 5.5-6.5 7c0 .5 0 1 0 1.5c.9-.1 .9-.1 5.5-.5c1 1.3 2 2.7 3 4c-.2 .5-.3 1-.5 1.5c-1.7 .5-3.2 1-5 1.6c-5.5 1.9-5.5 1.9-11 5.4c-.4 .1-.4 .1-2.5 .5c-1.5-1.5-1.5-1.5-2.5-6c-.4-.3-.6-.7-1-1c0-6.7 .9-11.7 3-18c.2-.6 .2-.6 1-3.5c7.8-9.7 20.5-14.1 32-9.5z"/><path fill="#FAF9F8" d="M103.5 40c0 .4 0 .6 0 1c2.2-.1 4.3-.4 6.5-.5c7 0 7 0 9.5 1c-.2 .6-.3 1.3-.5 2c-1.8 .6-3.7 1.3-5.5 2c-.2 .5-.3 1-.5 1.5c.7-.1 1.3-.4 2-.5c13.4-.3 13.4-.3 19.5 3.5c-.3 .6-.7 1.3-1 2c-1 0-2 0-3 0c0 .5 0 1 0 1.5c1.2 .1 1.2 .1 7 .5c3.5 2 3.5 2 5.5 4.5c0 1 0 2 0 3c-3.3 .9-6.7 1.6-10 2.5c-7.5 5-7.5 5-8.5 9c-.3 4.5-.4 9-.5 13.5c1.5 1 1.5 1 7.5 2.5c.9 .6 .9 .6 5.5 3.5c2 1 2 1 9 2.5c7.9 4 11.3 8.3 15 16.5c0 .5 0 1 0 1.5c-1 .2-2 .3-3 .5c.5 1.8 1 3.7 1.5 5.5c.3 8.5-.8 13.6-6 20.5c-.7-.2-1.3-.3-2-.5c0-1.7 0-3.3 0-5c-.7 .2-1.3 .3-2 .5c-1.3 2.7-2.7 5.3-4 8c-3.2 3.7-6.4 6.2-11 8c-1-1-1-1-1.5-3c-2.5 1.3-4.8 2.9-7 4.5c-6.3 2.8-10.8 3.2-17.6 1c-1.3-.5-2.6-1-3.9-1.5c.2 1.2 .3 2.3 .5 3.5c-.2 .3-.3 .7-.5 1c-5.9 0-9-1.8-14-5c-3-3.5-3-3.5-3-4.5c-.7 0-1.3 0-2 0c.5 2.2 1 4.3 1.5 6.5c-.3 .3-.7 .7-1 1c-4.5-.9-7.6-3.6-11-6.5c-4-6-4-9.3-4-16.5c2.5-7.5 2.5-7.5 3.5-9.5c.3-1.7 .7-3.3 1-5c6.4-8 14.7-13 24.5-16c6.4-6.3 6.4-6.3 8.5-10.5c0-6-1.6-9.8-4.5-15c-.2-.5-.3-1-.5-1.5c-1.7-.3-3.3-.7-5-1c-1.3-.7-2.7-1.3-4-2c-5.7-1.9-10.2-.3-15.5 1.5c-10 5.5-10 5.5-13 10c-.3-.2-.3-.2-2-1c1.3-6.8 4.6-11.7 9.5-16.5c.5-.3 1-.6 1.5-1c.2-.5 .3-1 .5-1.5c-1 .1-1 .1-6 1c-.3-.4-.7-.6-1-1c.5-2 .5-2 4-6c5-2.5 5-2.5 7.5-3c0-.5 0-1 0-1.5c-10.5-1.5-18.5 1-27 7c-.5 .9-1 1.6-1.5 2.5c-.9 .6-.9 .6-5.5 4c-4 3.5-7.6 7-11 11c-4.6 3.8-8.7 6.8-14 9.5c-.7-.3-1.3-.7-2-1c.2-.7 .3-1.3 .5-2c1.8-1 3.7-2 5.5-3c5.2-4.2 9.7-8.5 14-13.5c3.1-2.8 6.2-5.5 9.5-8c1.3-1.3 2.6-2.6 4-4c5.6-3.6 10.4-6.1 17-7c.5-1 1-2 1.5-3c7.2-2.4 12.5-2.2 20-.5c.2 .1 .2 .1 1 .5c3.5 0 3.5 0 8.5-3.5c1-.5 1-.5 3-.5zM118 92.5c-1 1.2-2 2.3-3 3.5c.5 2.5 .5 2.5 3 5c1.8 .8 3.7 1.7 5.5 2.5c.5 1 .5 1 .5 4.5c-4 4.8-6.1 5-12.5 6c0 .7 0 1.3 0 2c4.7 1.5 7.7 .2 12-1.5c1.2-.7 2.3-1.3 3.5-2c1.3 .3 2.7 .7 4 1c3.5 0 3.5 0 5.5-1.5c-.2-.5-.3-1-.5-1.5c-1.7-.3-3.3-.7-5-1c-2.5-2-2.5-2-3-5.5c2-3 2-3 4-5.5c.5-3.5 .5-3.5-1.5-6.5c-4.3-2.2-8.2-1-12.5 .5zM146 58.5c5.8 1.1 8.3 3.9 12.5 8c3.7 5.9 4.4 7.3 5 14c2.8 6.2 5.4 10 10.5 14.5c5.2 5.2 7.2 10.3 9 17.5c0 3.5 0 3.5-2 11c-.2 1.3-.2 1.3-1 7.5c-3.8 10.7-3.8 10.7-8.5 13.5c-.5 .2-.5 .2-3 1c-1 1.3-2 2.7-3 4c-4.5 3-4.5 3-10.5 5c-2.7-.6-5.3-1.3-8-2c-1.7 1.2-3.3 2.3-5 3.5c-2 1-2 1-4 .5c.5-1.5 .5-1.5 6-5c.3-.3 .3-.3 2-2c2.4 0 4.9 0 7.5 0c.5 .5 1 1 1.5 1.5c4.3-.6 7.1-2.3 10.5-5c3.5-5.5 3.5-5.5 4-7c1.5 0 2.9 0 4.5 0c4.3-8.7 4.6-14 2.5-23.5c-.3-1-.7-2-1-3c.3 .1 .3 .1 2 .5c1.5 2.5 1.5 2.5 1.5 4c.5-.2 1-.3 1.5-.5c-.8-10-4.3-16-11.4-22.8c-3.7-3.8-5.5-6.9-7.6-11.7c-.7-1.5-1.5-2.9-2.3-4.4c-1.7-3.6-1.7-3.6-1.7-5.6c.3-.2 .7-.3 1-.5c-.5-3-.5-3-4.5-7c-2-1-3.9-2-6-3c-.7-.4-1.3-.6-2-1c0-.6 0-1.3 0-2zM33.5 83.5c-1 4-5.1 6.4-8 9c-8.6 8.1-14.6 17.2-15.2 29.2c.3 8.1 .3 8.1 2.2 11.8c.5 0 1 0 1.5 0c.4-2.9 .6-6 1-9c.6 0 1.3 0 2 0c.2 1.3 .4 2.7 .6 4.1c1.8 9.3 5.3 14.8 12.9 20.4c2.3 1.2 4.6 2.3 7 3.5c.7 .6 .7 .6 4.5 3.5c3.5 0 3.5 0 8.5-1.5c1 0 2 0 3 0c-1 2-1 2-2.5 3.5c-6.5 1.7-9.6 .9-16-1.5c-1.1-1-2.3-2-3.5-3c-1.5-.7-2.9-1.3-4.5-2c-3.9-3.4-6.9-6.5-9.5-11c-1.1-.5-2.3-1-3.5-1.5c-5.9-6.7-8-12.7-8-21.5c2.4-12.2 7.2-19.8 16.5-28c4.5-4 4.5-4 6-5.5c3.5-1 3.5-1 5-.5zM50.5 172c2.5 1 2.5 1 7 6.5c6 6.2 12 11.8 18.8 17.1c5.7 4.5 11.3 9.1 16.7 13.9c1.3 1.2 2.7 2.3 4 3.5c.7 .7 1.3 1.3 2 2c-.2 .8-.3 1.7-.5 2.5c-6 2.9-11.7 4-18.5 4c-2.6-2.6-4.3-5.5-5.5-9c-.3 .1-.3 .1-1.5 .5c0 2.3 0 4.6 0 7c-.3 .5-.7 1-1 1.5c-13.3 0-24.6-4.6-34.5-13.5c-4.5-4.5-4.5-4.5-8.5-11c.5-2.5 .5-2.5 4.5-7c5-3 5-3 8-3.5c.5 .3 1 .7 1.5 1c2-.5 2-.5 4-2c.9-4.2 1.7-8.3 2.5-12.5c.4-.3 .6-.7 1-1z"/><path fill="#03B5BD" d="M58.5 149.5c2.5 .5 2.5 .5 7 3.5c9.8 5.2 18.9 7.9 30 8.5c2.2 .5 4.3 1 6.5 1.5c7 0 7 0 9-.5c2.3-.2 4.6-.3 7-.5c3.2-.9 6.3-1.9 9.5-3c1.3-.2 2.7-.3 4-.5c.2 .2 .3 .3 .5 .5c-.8 6.3-4.1 10.2-8 15c-.2 .5-.3 1-.5 1.5c1.7-1 3.3-2 5-3c.7 .2 1.3 .3 2 .5c-3 12.2-9.3 20.3-17.5 29.5c-.7 .7-.7 .7-4 4.5c-1 .8-2 1.7-3 2.5c-3.5-.5-3.5-.5-8-5c-2.3-1.8-4.7-3.7-7-5.5c-2.7-2.2-5.3-4.3-8-6.5c-.8-.6-.8-.6-4.5-3.5c-11-9.3-20.7-18.8-28.5-31c3-4 3-4 8.5-8.5z"/><ellipse cx="94" cy="94" rx="6.5" ry="6.5" fill="#212E34"/><ellipse cx="135" cy="85.6" rx="6.2" ry="5.8" fill="#212E34"/><g fill="#FAF9F8"><circle cx="91.5" cy="91.5" r="2.1"/><circle cx="134.6" cy="84.3" r="2"/></g><ellipse cx="124" cy="93.8" rx="3.4" ry="1.1" fill="#9B9D9E" transform="rotate(-6 124 93.8)"/></g></g><rect x="5" y="5" width="230" height="230" rx="45" fill="none"/></symbol></defs>
2
2
  <rect x="4" y="4" width="952" height="632" rx="18" fill="#FAF9F8" stroke="#C4CBCE" stroke-width="1.5"/>
3
3
  <path d="M4 22a18 18 0 0 1 18-18h916a18 18 0 0 1 18 18v24H4z" fill="#212E34"/>
4
4
  <circle cx="26" cy="25" r="5" fill="#C4CBCE" opacity="0.55"/>
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.7.0"
3
+ version = "1.8.0"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the herd, the state of a board's lanes, in a herdr pane"
6
6
  platforms = ["linux", "macos"]
package/install.sh CHANGED
@@ -9,7 +9,7 @@ set -eu
9
9
 
10
10
  # The default is the version this file ships with. The release bump changes it together with
11
11
  # package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
12
- JACKHERD_VERSION="${JACKHERD_VERSION:-1.7.0}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.8.0}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -1,15 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
- import { isRecord, type JsonObject } from '../shared/json'
3
-
4
- /** A client's session id as a UUID; nothing else may reach a path. */
5
- const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
6
-
7
- const sessionOf = (pane: JsonObject): string | null => {
8
- const session = isRecord(pane.agent_session) ? pane.agent_session.value : undefined
9
- return typeof pane.agent === 'string' && pane.agent !== '' && typeof session === 'string' && SESSION_ID.test(session)
10
- ? session
11
- : null
12
- }
2
+ import { isRecord } from '../shared/json'
3
+ import { sessionOf } from '../shared/pane-agent'
13
4
 
14
5
  /**
15
6
  * The sessions of the agents that run in the main checkout, from the `result` of `herdr pane list`: the coordinator
@@ -21,4 +12,3 @@ export const coordinatorSessions = (listing: unknown, main: string): string[] =>
21
12
  typeof pane.cwd === 'string' && resolve(pane.cwd) === resolve(main) ? (sessionOf(pane) ?? []) : [],
22
13
  )
23
14
  }
24
-
@@ -1,13 +1,14 @@
1
- // PermissionRequest hook of Claude Code, shipped by jackherd: the client adapter of the approval gate. In a lane it
2
- // holds a call that would open a permission dialog, records it for the coordinator and answers with the user's decision
3
- // from the coordinator's dialog; elsewhere, and on any failure, it answers nothing and the dialog stays. argv[2..5]: the
4
- // allow label, the deny label, the text for a call still pending after the wait, the text for a denied call, rendered
5
- // into the hook entry by `sync`.
1
+ // PermissionRequest hook of Claude Code, shipped by jackherd: the client adapter of the approval gate. In a lane, known
2
+ // by the herdr agent of the pane and not by the directory of the call, it holds a call that would open a permission
3
+ // dialog, records it for the coordinator and answers with the user's decision from the coordinator's dialog; elsewhere,
4
+ // and on any failure, it answers nothing and the dialog stays. argv[2..5]: the allow label, the deny label, the text for
5
+ // a call still pending after the wait, the text for a denied call, rendered into the hook entry by `sync`.
6
6
  import { homedir } from 'node:os'
7
7
  import { mainCheckoutOf } from '../shared/checkout'
8
8
  import { commandOutput } from '../shared/command'
9
9
  import { readConfig } from '../shared/config'
10
10
  import { isRecord } from '../shared/json'
11
+ import { ownPaneAgent } from '../shared/own-pane'
11
12
  import { coordinatorSessions } from './herdr-panes'
12
13
  import { type ClientCall, type GateDependencies, type GateOutcome, runGate } from './run'
13
14
  import { approvalsDirectory, claimRequest, pruneRequests, readRequests, writeRequest } from './store'
@@ -22,15 +23,16 @@ const herdrPanes = (): unknown => {
22
23
  return answer.result
23
24
  }
24
25
 
25
- /** Claude Code's hook input: `cwd`, `tool_name`, `tool_input`. */
26
+ /** Claude Code's hook input: `cwd`, `session_id`, `tool_name`, `tool_input`. */
26
27
  const clientCallOf = (text: string): ClientCall | null => {
27
28
  const input: unknown = JSON.parse(text)
28
29
  if (!isRecord(input) || typeof input.cwd !== 'string' || typeof input.tool_name !== 'string') return null
29
- return { cwd: input.cwd, tool: input.tool_name, input: input.tool_input ?? null }
30
+ if (typeof input.session_id !== 'string') return null
31
+ return { cwd: input.cwd, session: input.session_id, tool: input.tool_name, input: input.tool_input ?? null }
30
32
  }
31
33
 
32
34
  const dependencies: GateDependencies = {
33
- inLaneSession: process.env.HERDR_ENV === '1',
35
+ paneAgent: () => ownPaneAgent(),
34
36
  mainCheckoutOf,
35
37
  config: (main) => readConfig([main, homedir()]),
36
38
  readRequests: (main) => readRequests(approvalsDirectory(main)),
@@ -1,20 +1,22 @@
1
1
  import type { DialogAnswer } from '../shared/dialog-answer'
2
+ import { ASK_USER_QUESTION_TOOL } from '../shared/dialog-answers'
2
3
  import { isFresh } from '../shared/fresh'
4
+ import { laneOfAgent } from '../shared/lane'
5
+ import type { PaneAgent } from '../shared/pane-agent'
3
6
  import { gateDecision, type GateLabels } from './decision'
4
- import { laneOf } from './lane'
5
7
  import { type ApprovalRequest, callHash, type GateCall, isStorableInput, newRequest, requestId } from './request'
6
8
  import { gateSettingsOf } from './settings'
7
9
 
8
10
  /** A call as the client's hook hands it over, before it is known to be a lane's. */
9
- export type ClientCall = { cwd: string; tool: string; input: unknown }
11
+ export type ClientCall = { cwd: string; session: string; tool: string; input: unknown }
10
12
 
11
13
  /** What the gate decided; null decides nothing, so the client's own dialog stays. */
12
14
  export type GateOutcome = { kind: 'allow' } | { kind: 'denied' } | { kind: 'pending' } | null
13
15
 
14
16
  /** Everything with a side effect, passed in so a test reaches the flow without a multiplexer, git or files. */
15
17
  export type GateDependencies = {
16
- /** Whether this session runs in a pane of the terminal multiplexer. */
17
- inLaneSession: boolean
18
+ /** The agent of the multiplexer pane this session runs in; null outside the multiplexer. */
19
+ paneAgent: () => PaneAgent | null
18
20
  mainCheckoutOf: (cwd: string) => string
19
21
  config: (main: string) => Record<string, unknown>
20
22
  readRequests: (main: string) => ApprovalRequest[]
@@ -70,24 +72,31 @@ const awaitDecision = async (
70
72
  }
71
73
  }
72
74
 
75
+ /** A question to the user asks for an answer, not for a permission: the lane asks the coordinator in its text instead. */
76
+ const isHeld = ({ tool, input }: ClientCall): boolean => tool !== ASK_USER_QUESTION_TOOL && isStorableInput(input)
77
+
73
78
  /**
74
79
  * The gate for a call that would open a permission dialog in a lane: it records the call as a request the watcher
75
- * reports, waits for the user's answer to the coordinator's dialog, and allows or denies. Outside a lane, for a call too
76
- * large to store, and on any failure (the multiplexer or git cannot be asked), it decides nothing, so the dialog stays.
80
+ * reports, waits for the user's answer to the coordinator's dialog, and allows or denies. The lane is the agent of the
81
+ * pane, never the directory of the call. Outside a lane, for a question or a call too large to store, and on any failure
82
+ * (the multiplexer or git cannot be asked), it decides nothing, so the dialog stays.
77
83
  */
78
84
  export const runGate = async (
79
85
  clientCall: ClientCall,
80
86
  labels: GateLabels,
81
87
  dependencies: GateDependencies,
82
88
  ): Promise<GateOutcome> => {
83
- if (!dependencies.inLaneSession || !isStorableInput(clientCall.input)) return null
89
+ if (!isHeld(clientCall)) return null
84
90
  try {
85
- const main = dependencies.mainCheckoutOf(clientCall.cwd)
86
- const settings = gateSettingsOf(dependencies.config(main))
87
- const lane = laneOf({ cwd: clientCall.cwd, main, worktreeDirectory: settings.worktreeDirectory })
91
+ const agent = dependencies.paneAgent()
92
+ if (agent === null || agent.cwd === null) return null
93
+ const main = dependencies.mainCheckoutOf(agent.cwd)
94
+ const { worktreeDirectory, waitMinutes } = gateSettingsOf(dependencies.config(main))
95
+ const lane = laneOfAgent({ agent, session: clientCall.session, main, worktreeDirectory })
88
96
  if (!lane) return null
89
- const request = openRequest(main, { lane, ...clientCall }, dependencies)
90
- return await awaitDecision(main, request.hash, labels, settings.waitMinutes, dependencies)
97
+ const { cwd, tool, input } = clientCall
98
+ const request = openRequest(main, { lane, cwd, tool, input }, dependencies)
99
+ return await awaitDecision(main, request.hash, labels, waitMinutes, dependencies)
91
100
  } catch {
92
101
  return null
93
102
  }
@@ -1,3 +1,5 @@
1
+ import { ASK_USER_QUESTION_TOOL } from '../shared/dialog-answers'
2
+
1
3
  export type HintInput = {
2
4
  message: string
3
5
  tools: string[]
@@ -8,8 +10,6 @@ export type HintInput = {
8
10
 
9
11
  export type HintDecision = { kind: 'silent' } | { kind: 'hint'; message: string }
10
12
 
11
- const ASK_TOOL = 'AskUserQuestion'
12
-
13
13
  /** A lane prompt names the lane in its first line; a lane started from a hand-over file reads the file first. */
14
14
  const LANE_PROMPT = /^\s*(<pasted_content[^>]*>\s*)?(Du bist die Spur|You are the lane|Read `\.claude\/handover\/)/i
15
15
 
@@ -23,6 +23,6 @@ export const isLanePrompt = (firstPrompt: string): boolean => LANE_PROMPT.test(f
23
23
 
24
24
  export const decideHint = (input: HintInput): HintDecision => {
25
25
  if (!input.enabled || input.stopHookActive || input.lane) return { kind: 'silent' }
26
- if (!endsWithQuestion(input.message) || input.tools.includes(ASK_TOOL)) return { kind: 'silent' }
26
+ if (!endsWithQuestion(input.message) || input.tools.includes(ASK_USER_QUESTION_TOOL)) return { kind: 'silent' }
27
27
  return { kind: 'hint', message: HINT }
28
28
  }
@@ -1,4 +1,5 @@
1
1
  import { posix } from 'node:path'
2
+ import { commandTextRunnerIndex, searchableText } from '../shared/command-text-runners'
2
3
  import {
3
4
  ASSIGNMENT,
4
5
  inlineScript,
@@ -7,8 +8,26 @@ import {
7
8
  programName,
8
9
  readsScriptFromInput,
9
10
  ruleStart,
11
+ runsInterpreterProgram,
10
12
  } from '../shared/program-position'
11
- import { parseShell, ShellParseError, type SimpleCommand, type Word } from '../shared/shell'
13
+ import {
14
+ expandingHereDocumentAdvice,
15
+ isExpanded,
16
+ parseShell,
17
+ ShellParseError,
18
+ type SimpleCommand,
19
+ UNREADABLE_ADVICE,
20
+ type Word,
21
+ } from '../shared/shell'
22
+ import {
23
+ definesShellAlias,
24
+ mayRunDefinedFunction,
25
+ NO_DEFINITIONS,
26
+ type ShellDefinitions,
27
+ shellDefinitionAt,
28
+ shellDefinitionHint,
29
+ } from '../shared/shell-aliases'
30
+ import { type PushCall, type PushContext, pushTargetDenial } from './push-target'
12
31
 
13
32
  export type GitGuardDecision = { kind: 'pass' } | { kind: 'deny'; reason: string }
14
33
 
@@ -64,6 +83,9 @@ type SplitArguments = { options: string[]; operands: string[] }
64
83
 
65
84
  const PASS: GitGuardDecision = { kind: 'pass' }
66
85
 
86
+ /** The programs whose alias or function the guard refuses: one that runs git or gh hides it from the rules (D42). */
87
+ const DEFINITION_PROGRAMS = ['git', 'gh']
88
+
67
89
  const FORCE_PUSH =
68
90
  'A force push is refused. Use `--force-with-lease` instead, and only after the user agreed to rewrite the remote branch.'
69
91
  const SKIP_HOOKS =
@@ -75,6 +97,10 @@ const PROGRAM_HINT =
75
97
  'Refused: the program is expanded from a variable or a substitution and is followed by a protected git verb, so the permission rules cannot see it. Write `git` plainly.'
76
98
  const BRACE_HINT =
77
99
  'Refused: an option of this git call is written as a brace expansion, so neither the permission rules nor this hook can tell what git receives. Write the option plainly.'
100
+ const INLINE_EXPANSION_HINT =
101
+ 'Refused: `eval` or `sh -c` runs a script held in a variable or a substitution, so neither the permission rules nor this hook can read it. Write the commands out.'
102
+ const EXPANDED_FUNCTION_HINT =
103
+ 'Refused: the program is expanded from a variable or a substitution, and it may name a shell function that runs git or gh, which hides the command from the permission rules. Run the command written out instead.'
78
104
  const ALIAS_HINT =
79
105
  'Refused: `-c alias.…` defines a git alias inline, which hides the verb from the permission rules. Run the git command it stands for directly.'
80
106
 
@@ -149,6 +175,19 @@ const HOOK_SKIPPING_VARIABLE = /^(HUSKY|LEFTHOOK|SKIP)\+?=/
149
175
  const REDIRECTING_VARIABLE = /^(GIT_CONFIG\w*|GIT_DIR|GIT_WORK_TREE|GIT_INDEX_FILE)\+?=/
150
176
  /** An option the shell rewrites before git sees it: `--forc{e,}` reaches git as `--force`. */
151
177
  const BRACE_IN_OPTION = /^-.*\{.*(,|\.\.).*\}/
178
+ /** Push options that send every local branch, the base branch among them. */
179
+ const ALL_BRANCHES = ['--all', '--branches', '--mirror']
180
+ const DIRECTORY_CHANGERS = new Set(['cd', 'pushd', 'popd'])
181
+ /** Git verbs that can change which branch `HEAD` names, or where a push of it goes. */
182
+ const BRANCH_MOVING_VERBS = new Set([
183
+ PROTECTED_VERB.checkout,
184
+ 'switch',
185
+ 'branch',
186
+ 'config',
187
+ 'symbolic-ref',
188
+ 'update-ref',
189
+ 'worktree',
190
+ ])
152
191
  const PROTECTED_MENTION = new RegExp(`\\bgit\\b[\\s\\S]*\\b(${Object.values(PROTECTED_VERB).join('|')})\\b`)
153
192
 
154
193
  const isProtectedVerb = (value: string): value is ProtectedVerb =>
@@ -320,16 +359,65 @@ const denyIfMentionsProtectedVerb = (script: string, reason: string): GitGuardDe
320
359
  return verb ? deny(`Refused: ${reason}, and it mentions \`git ${verb}\`. ${plainInstead(verb)}`) : PASS
321
360
  }
322
361
 
323
- const judgeWord = (words: SimpleCommand, index: number, context: ScriptContext): GitGuardDecision => {
362
+ const judgeDefinition = (
363
+ words: SimpleCommand,
364
+ index: number,
365
+ definitions: () => ShellDefinitions,
366
+ ): GitGuardDecision => {
367
+ const definition = shellDefinitionAt(words, index, definitions, DEFINITION_PROGRAMS)
368
+ return definition ? deny(`Refused: ${shellDefinitionHint(definition)}`) : PASS
369
+ }
370
+
371
+ /**
372
+ * A program that runs command text it is given (`watch '…'`, sed's `e`) is refused when that text names a protected
373
+ * verb; when the text is itself an expansion, the searchable script is searched instead.
374
+ */
375
+ const judgeCommandText = (words: SimpleCommand, searchable: string): GitGuardDecision => {
376
+ const runner = commandTextRunnerIndex(words)
377
+ if (runner === -1) return PASS
378
+ const given = words.slice(runner + 1)
379
+ const text = given.some(({ value }) => isExpanded(value)) ? searchable : given.map(({ value }) => value).join(' ')
380
+ return denyIfMentionsProtectedVerb(text, 'a program runs command text it is given, which no rule inspects')
381
+ }
382
+
383
+ const judgeWord = (
384
+ words: SimpleCommand,
385
+ index: number,
386
+ context: ScriptContext,
387
+ definitions: () => ShellDefinitions,
388
+ searchable: string,
389
+ ): GitGuardDecision => {
324
390
  const script = inlineScript(words, index)
325
- if (script !== undefined) return judgeScript(script, SCRIPT_CONTEXT.nested)
326
- if (!isGit(words[index])) return PASS
391
+ if (script !== undefined) return judgeScript(script, SCRIPT_CONTEXT.nested, definitions, searchable)
392
+ if (!isGit(words[index])) return judgeDefinition(words, index, definitions)
327
393
  const invocation = readInvocation(words, index)
328
394
  return invocation ? judgeInvocation(invocation, gitPosition(words, index, context)) : PASS
329
395
  }
330
396
 
331
- const judgeCommand = (words: SimpleCommand, context: ScriptContext): GitGuardDecision =>
332
- firstDenial([...words.map((_word, index) => judgeWord(words, index, context)), judgeExpandedProgram(words)])
397
+ /** Inside `eval` or `sh -c`, an expanded program means the script was held in a variable: refused after D22. */
398
+ const judgeInlineExpansion = (words: SimpleCommand, context: ScriptContext): GitGuardDecision =>
399
+ context === SCRIPT_CONTEXT.nested && runsExpandedProgram(words) ? deny(INLINE_EXPANSION_HINT) : PASS
400
+
401
+ const judgeExpandedFunction = (
402
+ words: SimpleCommand,
403
+ definitions: () => ShellDefinitions,
404
+ searchable: string,
405
+ ): GitGuardDecision =>
406
+ mayRunDefinedFunction(words, definitions, DEFINITION_PROGRAMS, searchable) ? deny(EXPANDED_FUNCTION_HINT) : PASS
407
+
408
+ const judgeCommand = (
409
+ words: SimpleCommand,
410
+ context: ScriptContext,
411
+ definitions: () => ShellDefinitions,
412
+ searchable: string,
413
+ ): GitGuardDecision =>
414
+ firstDenial([
415
+ ...words.map((_word, index) => judgeWord(words, index, context, definitions, searchable)),
416
+ judgeInlineExpansion(words, context),
417
+ judgeExpandedProgram(words),
418
+ judgeCommandText(words, searchable),
419
+ judgeExpandedFunction(words, definitions, searchable),
420
+ ])
333
421
 
334
422
  /**
335
423
  * What no single command shows. Each check reads the whole script, so a harmless git call beside
@@ -340,21 +428,101 @@ const judgeScriptWide = (script: string, commands: SimpleCommand[]): GitGuardDec
340
428
  if (commands.some(readsScriptFromInput)) {
341
429
  return denyIfMentionsProtectedVerb(script, 'a shell reads its script from input here, which no rule inspects')
342
430
  }
431
+ if (commands.some(runsInterpreterProgram)) {
432
+ return denyIfMentionsProtectedVerb(
433
+ script,
434
+ 'an interpreter runs program text given inline or on its input, which no rule inspects',
435
+ )
436
+ }
437
+ if (commands.some(definesShellAlias)) {
438
+ return denyIfMentionsProtectedVerb(
439
+ script,
440
+ 'the script defines a shell alias, which hides its command from the rules',
441
+ )
442
+ }
343
443
  if (commands.some(setsGitVariable)) {
344
- return denyIfMentionsProtectedVerb(script, 'a `HUSKY`, `LEFTHOOK`, `SKIP` or `GIT_*` variable is set, which changes the git calls')
444
+ return denyIfMentionsProtectedVerb(
445
+ script,
446
+ 'a `HUSKY`, `LEFTHOOK`, `SKIP` or `GIT_*` variable is set, which changes the git calls',
447
+ )
345
448
  }
346
449
  if (commands.some(runsExpandedProgram)) {
347
- return denyIfMentionsProtectedVerb(script, 'a program is expanded from a variable or a substitution and could be git')
450
+ return denyIfMentionsProtectedVerb(
451
+ script,
452
+ 'a program is expanded from a variable or a substitution and could be git',
453
+ )
348
454
  }
349
455
  return PASS
350
456
  }
351
457
 
352
- const judgeScript = (script: string, context: ScriptContext): GitGuardDecision => {
458
+ /** `whole` is the text the mention search reads at the outer level: a nested script's variables are set there. */
459
+ const judgeParsed = (
460
+ commands: SimpleCommand[],
461
+ context: ScriptContext,
462
+ definitions: () => ShellDefinitions,
463
+ whole: string,
464
+ ): GitGuardDecision => {
465
+ const text = searchableText(whole, commands)
466
+ return firstDenial([
467
+ ...commands.map((words) => judgeCommand(words, context, definitions, text)),
468
+ judgeScriptWide(text, commands),
469
+ ])
470
+ }
471
+
472
+ const judgeScript = (
473
+ script: string,
474
+ context: ScriptContext,
475
+ definitions: () => ShellDefinitions,
476
+ whole: string,
477
+ ): GitGuardDecision => judgeParsed(parseShell(script), context, definitions, whole)
478
+
479
+ /** A `cd` or a git call that switches, renames or reconfigures the branch: `HEAD` at a later push is another one. */
480
+ const movesHead = (words: SimpleCommand): boolean =>
481
+ words.some((word, index) => {
482
+ if (!isProgramPosition(words, index)) return false
483
+ if (DIRECTORY_CHANGERS.has(programName(word))) return true
484
+ return isGit(word) && BRANCH_MOVING_VERBS.has(readInvocation(words, index)?.verb.value ?? '')
485
+ })
486
+
487
+ /** A plain `git push` where a rule matches; every other form of it is refused before this is asked. */
488
+ const pushCallOf = (words: SimpleCommand, headMoves: boolean): PushCall | undefined => {
489
+ const start = ruleStart(words)
490
+ const program = words.at(start)
491
+ if (program === undefined || !isGit(program) || words[start + 1]?.value !== PROTECTED_VERB.push) return undefined
492
+ const { options, operands } = splitArguments(words.slice(start + 2), VERB_SYNTAX.push)
493
+ const allBranches = options.some((option) => ALL_BRANCHES.some((name) => matchesLong(option, name)))
494
+ return { operands, allBranches, headMoves }
495
+ }
496
+
497
+ const judgePushes = (commands: SimpleCommand[], context: PushContext): GitGuardDecision => {
498
+ const headMoves = commands.some(movesHead)
499
+ const calls = commands.flatMap((words) => pushCallOf(words, headMoves) ?? [])
500
+ const reason = calls.map((call) => pushTargetDenial(call, context)).find((each) => each !== undefined)
501
+ return reason ? deny(reason) : PASS
502
+ }
503
+
504
+ /**
505
+ * Quotes split a word (`g\it`, `pu''sh`), an expansion hides a command: in text the parse cannot follow, no search
506
+ * can tell whether a protected git verb runs, so every unreadable script is refused (fail closed).
507
+ */
508
+ const denyUnreadable = ({ message }: ShellParseError): GitGuardDecision =>
509
+ deny(
510
+ `Refused: the command could not be read (${message}), so neither the permission rules nor this hook can tell whether it runs git. ${UNREADABLE_ADVICE}`,
511
+ )
512
+
513
+ /** A refusal of a script with an expanding here-document says how to keep its text from running. */
514
+ const withHereDocumentAdvice = (decision: GitGuardDecision, script: string): GitGuardDecision => {
515
+ const advice = expandingHereDocumentAdvice(script)
516
+ return decision.kind === 'deny' && advice !== '' ? deny(`${decision.reason} ${advice}`) : decision
517
+ }
518
+
519
+ const judgeTop = (command: string, push: PushContext, definitions: () => ShellDefinitions): GitGuardDecision => {
353
520
  try {
354
- const commands = parseShell(script)
355
- return firstDenial([...commands.map((words) => judgeCommand(words, context)), judgeScriptWide(script, commands)])
521
+ const commands = parseShell(command)
522
+ const decision = judgeParsed(commands, SCRIPT_CONTEXT.top, definitions, command)
523
+ return decision.kind === 'deny' ? decision : judgePushes(commands, push)
356
524
  } catch (error) {
357
- if (error instanceof ShellParseError) return denyIfMentionsProtectedVerb(script, 'the command could not be parsed')
525
+ if (error instanceof ShellParseError) return denyUnreadable(error)
358
526
  throw error
359
527
  }
360
528
  }
@@ -363,7 +531,13 @@ const judgeScript = (script: string, context: ScriptContext): GitGuardDecision =
363
531
  * Decides on a Bash command before it runs. Denies a protected git verb that a permission rule
364
532
  * written as `git <verb> …` would not match (options or `GIT_*` variables before the verb, a
365
533
  * wrapper, a path to git, a quoted verb, a line continuation, `sh -c`, `eval`, a script piped into
366
- * a shell), and denies a force push, a skipped hook and a hard reset in any form. Everything else
367
- * passes on to the project's own permission rules.
534
+ * a shell, an interpreter's program text), and denies a force push, a skipped hook and a hard reset in any
535
+ * form. Denies a push that updates the base branch, or whose target it cannot tell, every script it cannot read,
536
+ * and a shell alias or function (`definitions`, read only for a word that is no known program) that runs git or gh.
537
+ * Everything else passes on to the project's own permission rules.
368
538
  */
369
- export const decideBashCommand = (command: string): GitGuardDecision => judgeScript(command, SCRIPT_CONTEXT.top)
539
+ export const decideBashCommand = (
540
+ command: string,
541
+ push: PushContext,
542
+ definitions: () => ShellDefinitions = () => NO_DEFINITIONS,
543
+ ): GitGuardDecision => withHereDocumentAdvice(judgeTop(command, push, definitions), command)
@@ -1,13 +1,23 @@
1
1
  // PreToolUse hook for Bash, shipped by jackherd. Reads the hook input on stdin and
2
2
  // answers with a deny decision on stdout, or with nothing to leave the call to the permission rules.
3
3
  // Any failure denies: a guard that crashes must not wave the command through.
4
+ import { once } from '../shared/once'
5
+ import { readShellDefinitions } from '../shared/shell-snapshots'
4
6
  import { decideBashCommand, type GitGuardDecision } from './decision'
7
+ import { repositoryPushContext } from './repository'
5
8
 
6
- type HookInput = { tool_input?: { command?: unknown } }
9
+ type HookInput = { cwd?: unknown; tool_input?: { command?: unknown } }
7
10
 
8
11
  const decideInput = (text: string): GitGuardDecision | undefined => {
9
- const command = (JSON.parse(text) as HookInput).tool_input?.command
10
- return typeof command === 'string' ? decideBashCommand(command) : undefined
12
+ const input = JSON.parse(text) as HookInput
13
+ const command = input.tool_input?.command
14
+ const cwd = typeof input.cwd === 'string' ? input.cwd : process.cwd()
15
+ if (typeof command !== 'string') return undefined
16
+ return decideBashCommand(
17
+ command,
18
+ repositoryPushContext(cwd),
19
+ once(() => readShellDefinitions()),
20
+ )
11
21
  }
12
22
 
13
23
  const failureDecision = (error: unknown): GitGuardDecision => ({
@@ -0,0 +1,85 @@
1
+ import { expandsBraces, isExpanded } from '../shared/shell'
2
+
3
+ /**
4
+ * What only the repository knows about a push, asked only when the script holds one: a guard that reads the config
5
+ * or runs git for every Bash call would cost each of them.
6
+ */
7
+ export type PushContext = {
8
+ /** The project's base branch; undefined when the config cannot be read or names one that is no branch name. */
9
+ baseBranch: () => string | undefined
10
+ /**
11
+ * The branches a push without a refspec, or with `HEAD`, updates: the current branch and the one its push goes to;
12
+ * undefined when git cannot tell (a detached HEAD).
13
+ */
14
+ defaultPushBranches: () => string[] | undefined
15
+ }
16
+
17
+ /** A plain `git push` of the script, as the guard reads it. */
18
+ export type PushCall = {
19
+ /** The words after `git push` that are neither options nor their values: the remote and the refspecs. */
20
+ operands: string[]
21
+ /** `--all`, `--branches` or `--mirror`: every local branch, the base branch among them. */
22
+ allBranches: boolean
23
+ /** The script changes directory or branch, so the branch `HEAD` names at the push is not the one git reports now. */
24
+ headMoves: boolean
25
+ }
26
+
27
+ const HEAD_NAMES = new Set(['HEAD', '@'])
28
+ /** A `?` or `[` glob; `*` is a refspec wildcard as well and counts as the base branch below. */
29
+ const GLOB = /[?[]/
30
+ const BRANCH_PREFIX = /^(refs\/)?(heads\/)?/
31
+
32
+ const branchOf = (ref: string): string => ref.replace(BRANCH_PREFIX, '')
33
+
34
+ /**
35
+ * The branch a refspec updates on the remote: the part after `:`, or the source when there is none (`main`,
36
+ * `+main`, `main:`); empty for the matching refspec `:`; undefined for `HEAD`, which pushes the current branch under its
37
+ * push name.
38
+ */
39
+ const destinationOf = (refspec: string): string | undefined => {
40
+ const plain = refspec.replace(/^\+/, '')
41
+ const colon = plain.indexOf(':')
42
+ const destination = colon === -1 || colon === plain.length - 1 ? plain.replace(/:$/, '') : plain.slice(colon + 1)
43
+ return HEAD_NAMES.has(destination) ? undefined : branchOf(destination)
44
+ }
45
+
46
+ const isRewrittenByShell = (operand: string): boolean => GLOB.test(operand) || expandsBraces(operand)
47
+
48
+ /** A wildcard refspec, or the matching refspec `:` whose empty destination stands for every branch both sides have. */
49
+ const reachesEveryBranch = (destination: string | undefined): boolean =>
50
+ destination !== undefined && (destination === '' || destination.includes('*'))
51
+
52
+ const toBase = (base: string): string =>
53
+ `Refused: this push updates \`${base}\`, the base branch (baseBranch in .jackherd/config.json), and goes around the merge guard. Push your own branch and merge it through a pull request.`
54
+
55
+ const unknownTarget = (why: string): string =>
56
+ `Refused: the guard cannot tell which branch this push updates (${why}), and a push to the base branch goes around the merge guard. Name the branch plainly, \`git push origin <branch>\`, as a command of its own.`
57
+
58
+ const INVALID_BASE =
59
+ 'Refused: .jackherd/config.json cannot be read or its baseBranch is no branch name, so the guard cannot tell whether this push updates the base branch. Fix the config.'
60
+
61
+ /** Whether HEAD decides the target: no refspec, or one whose destination is `HEAD`. */
62
+ const pushesHead = (operands: string[], destinations: (string | undefined)[]): boolean =>
63
+ operands.length <= 1 || destinations.includes(undefined)
64
+
65
+ const headDenial = (call: PushCall, context: PushContext, base: string): string | undefined => {
66
+ if (call.headMoves) return unknownTarget('the script changes directory or branch before it')
67
+ const branches = context.defaultPushBranches()
68
+ if (branches === undefined) return unknownTarget('git cannot tell the current branch')
69
+ return branches.some((branch) => branchOf(branch) === base) ? toBase(base) : undefined
70
+ }
71
+
72
+ /**
73
+ * Why a plain `git push` is refused, or undefined when it does not reach the base branch. Every operand counts as a
74
+ * refspec, the remote too (`--repo` makes the first one a refspec): that can only refuse more, never less.
75
+ */
76
+ export const pushTargetDenial = (call: PushCall, context: PushContext): string | undefined => {
77
+ if (call.operands.some(isExpanded)) return unknownTarget('a refspec is expanded')
78
+ if (call.operands.some(isRewrittenByShell)) return unknownTarget('the shell rewrites a refspec by a glob or braces')
79
+ const base = context.baseBranch()
80
+ if (base === undefined) return INVALID_BASE
81
+ if (call.allBranches) return toBase(base)
82
+ const destinations = call.operands.map(destinationOf)
83
+ if (destinations.some((destination) => reachesEveryBranch(destination) || destination === base)) return toBase(base)
84
+ return pushesHead(call.operands, destinations) ? headDenial(call, context, base) : undefined
85
+ }