jackherd 1.13.0 → 1.15.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 (95) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +28 -38
  3. package/assets/icons/skills.svg +1 -0
  4. package/dist/config.d.ts +12 -1
  5. package/dist/config.js +35 -2
  6. package/dist/plan-format.d.ts +24 -1
  7. package/dist/plan-format.js +25 -5
  8. package/docs/plan-contract.md +84 -0
  9. package/docs/skills-dark.svg +15 -2
  10. package/docs/skills.svg +15 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/herdr-plugin/restore-plans.ts +56 -6
  13. package/install.sh +1 -1
  14. package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
  15. package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
  16. package/kit/hooks/hooks/merge-guard/run.ts +6 -3
  17. package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
  18. package/kit/hooks/hooks/model-log/transcript.ts +4 -7
  19. package/kit/hooks/hooks/open-question/record.ts +4 -3
  20. package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
  21. package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
  22. package/kit/hooks/hooks/shared/transcript.ts +7 -4
  23. package/kit/hooks/hooks/task-hint/decision.ts +3 -2
  24. package/kit/hooks/hooks/task-hint/run.ts +2 -4
  25. package/kit/rules/rules/kit/pull-requests.md +1 -0
  26. package/kit/workflow/skills/orchestrate/SKILL.md +24 -10
  27. package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
  28. package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
  29. package/kit/workflow/skills/orchestrate/references/events.md +6 -1
  30. package/kit/workflow/skills/orchestrate/references/plan-commit.md +7 -6
  31. package/kit/workflow/skills/start/SKILL.md +6 -2
  32. package/kit/workflow/skills/start/references/missing-commands.md +17 -0
  33. package/package.json +1 -1
  34. package/schemas/jackherd.schema.json +43 -0
  35. package/src/check/command.ts +53 -7
  36. package/src/check/file-kinds.ts +144 -0
  37. package/src/check/parallel-report.ts +10 -3
  38. package/src/check/plan.ts +118 -16
  39. package/src/check/report.ts +44 -1
  40. package/src/check/roots.ts +9 -0
  41. package/src/check/schema.ts +12 -0
  42. package/src/check/settings.ts +11 -3
  43. package/src/check/skill-budget-defaults.ts +21 -7
  44. package/src/check/skill-budgets.ts +49 -19
  45. package/src/claude/paths.ts +12 -0
  46. package/src/cli.ts +1 -0
  47. package/src/config/schema.ts +27 -0
  48. package/src/context/fill.ts +2 -1
  49. package/src/context/log-vocabulary.ts +22 -0
  50. package/src/context/session-log.ts +2 -1
  51. package/src/lane-prompt/build.ts +10 -5
  52. package/src/lane-prompt/command.ts +6 -1
  53. package/src/lane-prompt/input.ts +4 -4
  54. package/src/lane-prompt/texts.ts +18 -10
  55. package/src/plan-contract/command.ts +69 -0
  56. package/src/plan-contract/contract.ts +144 -0
  57. package/src/plan-format/dependencies.ts +1 -4
  58. package/src/plan-format/index.ts +4 -0
  59. package/src/plan-format/lanes.ts +11 -0
  60. package/src/plan-format/markdown.ts +6 -5
  61. package/src/plan-format/model-table.ts +27 -0
  62. package/src/plans/activity-read.ts +98 -0
  63. package/src/plans/ask-coordinator.ts +20 -11
  64. package/src/plans/lane-plans.ts +2 -1
  65. package/src/plans/layout.ts +49 -4
  66. package/src/plans/live.ts +17 -4
  67. package/src/plans/plan-panes.ts +25 -5
  68. package/src/plans/snapshot.ts +18 -8
  69. package/src/plans/subagents-read.ts +4 -3
  70. package/src/plans/subagents.ts +2 -1
  71. package/src/plans/texts.ts +27 -1
  72. package/src/settled/send.ts +80 -4
  73. package/src/shared/parse-json.ts +8 -0
  74. package/src/sync/command.ts +17 -10
  75. package/src/sync/foreign-skills.ts +40 -0
  76. package/src/sync/global-kit.ts +31 -21
  77. package/src/sync/init.ts +2 -2
  78. package/src/sync/kit-exclude.ts +39 -0
  79. package/src/sync/plan-file.ts +1 -1
  80. package/src/sync/render.ts +2 -1
  81. package/src/sync/report.ts +37 -2
  82. package/src/sync/sync-result.ts +5 -2
  83. package/src/sync/sync.ts +13 -5
  84. package/src/task-signals/claude-signals.ts +6 -5
  85. package/src/watch/command.ts +88 -18
  86. package/src/watch/lane-message.ts +1 -1
  87. package/src/watch/lane-progress.ts +57 -3
  88. package/src/watch/lane-report.ts +67 -2
  89. package/src/watch/lane-watch.ts +2 -8
  90. package/src/watch/plan-missing-read.ts +47 -0
  91. package/src/watch/plan-missing.ts +43 -0
  92. package/src/watch/reference-paths.ts +1 -0
  93. package/src/watch/task-parallel-touched.ts +9 -2
  94. package/src/watch/texts.ts +8 -0
  95. package/src/watch/watch-events.ts +7 -0
@@ -1,5 +1,6 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="960" height="530" viewBox="0 0 960 530" role="img" aria-labelledby="t d"><title id="t">The skills and what they hand on</title><desc id="d">/jh-grill hands decision rows to /jh-plan. /jh-plan feeds /jh-orchestrate with phases, Writes sets and a model per phase, or a single phase to /jh-implement; under /jh-orchestrate each lane runs /jh-implement. /jh-implement ends in /jh-pre-commit, which runs jh check, /jh-review, the jh-spec-review agent against the plan, and the reviewers routed by path: /jh-uncle-bob, /jh-security-audit, /jh-devops. /jh-diagnose hands a red command to /jh-implement.</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}</style></defs>
2
- <rect x="1" y="1" width="958" height="528" rx="18" fill="#151D22" stroke="#34434B" stroke-width="1.5"/>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="960" height="600" viewBox="0 0 960 600" role="img" aria-labelledby="t d"><title id="t">The skills and what they hand on</title><desc id="d">A plan of your own or an issue is adopted: Jack adds what is missing, shows you the diff and hands it to /jh-orchestrate. /jh-grill hands decision rows to /jh-plan. /jh-plan feeds /jh-orchestrate with phases, Writes sets and a model per phase, or a single phase to /jh-implement, which your own skill can replace; under /jh-orchestrate each lane runs /jh-implement or your own skill. /jh-implement ends in /jh-pre-commit, which runs jh check, /jh-review, the jh-spec-review agent against the plan, and the reviewers routed by path: /jh-uncle-bob, /jh-security-audit, /jh-devops. /jh-diagnose hands a red command to /jh-implement.</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}</style></defs>
2
+ <rect x="1" y="1" width="958" height="598" rx="18" fill="#151D22" stroke="#34434B" stroke-width="1.5"/>
3
+ <g transform="translate(0 70)">
3
4
  <path d="M120 103.5 125.3 103.4 130.4 103.3 135.4 103.3 140.3 103.2 145.1 103.1 149.8 103 154.5 103 159 102.9 163.5 102.8 168 102.7 172.5 102.6 176.9 102.6 181.3 102.5 185.8 102.4 190.2 102.3 194.7 102.2 199.2 102.2 203.9 102.1 208.5 102 213.3 101.9 218.2 101.8 223.1 101.8 228.2 101.7 233.5 101.6 238.8 101.5 244.4 101.5 250.1 101.4 256 101.3 A1.3 1.3 0 0 1 256 98.7 L256 98.7 250.1 98.6 244.4 98.5 238.8 98.5 233.5 98.4 228.2 98.3 223.1 98.2 218.2 98.2 213.3 98.1 208.5 98 203.9 97.9 199.2 97.8 194.7 97.8 190.2 97.7 185.8 97.6 181.3 97.5 176.9 97.4 172.5 97.4 168 97.3 163.5 97.2 159 97.1 154.5 97 149.8 97 145.1 96.9 140.3 96.8 135.4 96.7 130.4 96.7 125.3 96.6 120 96.5 A3.5 3.5 0 0 1 120 103.5Z" fill="#21BEC5"/>
4
5
  <path d="M249.2 106L257.2 100L249.2 94" fill="none" stroke="#21BEC5" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
5
6
  <path d="M320 103.5 325.3 103.4 330.6 103.3 335.8 103.3 340.9 103.2 346 103.1 351 103 356 103 361 102.9 366 102.8 370.9 102.7 375.9 102.6 380.8 102.6 385.8 102.5 390.8 102.4 395.7 102.3 400.8 102.2 405.8 102.2 410.9 102.1 416.1 102 421.3 101.9 426.6 101.8 431.9 101.8 437.4 101.7 442.9 101.6 448.5 101.5 454.2 101.5 460.1 101.4 466 101.3 A1.3 1.3 0 0 1 466 98.7 L466 98.7 460.1 98.6 454.2 98.5 448.5 98.5 442.9 98.4 437.4 98.3 431.9 98.2 426.6 98.2 421.3 98.1 416.1 98 410.9 97.9 405.8 97.8 400.8 97.8 395.7 97.7 390.8 97.6 385.8 97.5 380.8 97.4 375.9 97.4 370.9 97.3 366 97.2 361 97.1 356 97 351 97 346 96.9 340.9 96.8 335.8 96.7 330.6 96.7 325.3 96.6 320 96.5 A3.5 3.5 0 0 1 320 103.5Z" fill="#21BEC5"/>
@@ -44,6 +45,7 @@
44
45
  <text x="532" y="114" class="s" font-size="12.5" fill="#9B9D9E" text-anchor="start">lanes in herdr</text>
45
46
  <text x="500" y="340" class="m" font-size="14" fill="#E6EDF3" font-weight="700" text-anchor="middle">/jh-implement</text>
46
47
  <text x="500" y="358" class="s" font-size="12.5" fill="#9B9D9E" text-anchor="middle">builds, ticks</text>
48
+ <text x="500" y="382" class="s" font-size="12.5" fill="#9B9D9E" text-anchor="middle" font-style="italic">or your own skill</text>
47
49
  <text x="700" y="340" class="m" font-size="14" fill="#E6EDF3" font-weight="700" text-anchor="middle">/jh-pre-commit</text>
48
50
  <text x="700" y="358" class="s" font-size="12.5" fill="#9B9D9E" text-anchor="middle">jh check</text>
49
51
  <text x="880" y="200" class="m" font-size="14" fill="#E6EDF3" font-weight="700" text-anchor="middle">/jh-review</text>
@@ -62,4 +64,15 @@
62
64
  <text x="788" y="392" class="s" font-size="12.5" fill="#9B9D9E" text-anchor="end" font-style="italic">routed</text>
63
65
  <text x="788" y="408" class="s" font-size="12.5" fill="#9B9D9E" text-anchor="end" font-style="italic">by path</text>
64
66
  <text x="420" y="420" class="s" font-size="12.5" fill="#9B9D9E" text-anchor="start" font-style="italic">red command</text>
67
+ </g>
68
+ <text x="290" y="48" class="s" font-size="13" fill="#E6EDF3" font-weight="600" text-anchor="middle">your own plan</text>
69
+ <text x="290" y="66" class="s" font-size="13" fill="#E6EDF3" font-weight="600" text-anchor="middle">or an issue</text>
70
+ <path d="M350 52C400 52 440 52 466 52" fill="none" stroke="#7E8A90" stroke-width="2.6" stroke-linecap="round" stroke-dasharray="0.1 7"/>
71
+ <path d="M459.2 58L467.2 52L459.2 46" fill="none" stroke="#7E8A90" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
72
+ <path d="M496.5 82 496.6 84.9 496.7 87.7 496.7 90.4 496.8 93 496.9 95.4 497 97.8 497.1 100 497.1 102.2 497.2 104.2 497.3 106.2 497.4 108.1 497.4 110 497.5 111.8 497.6 113.5 497.7 115.2 497.8 116.8 497.8 118.5 497.9 120.1 498 121.6 498.1 123.2 498.1 124.8 498.2 126.3 498.3 127.9 498.4 129.4 498.5 131 498.5 132.7 498.6 134.3 498.7 136 A1.3 1.3 0 0 1 501.3 136 L501.3 136 501.4 134.3 501.5 132.7 501.5 131 501.6 129.4 501.7 127.9 501.8 126.3 501.9 124.8 501.9 123.2 502 121.6 502.1 120.1 502.2 118.5 502.2 116.8 502.3 115.2 502.4 113.5 502.5 111.8 502.6 110 502.6 108.1 502.7 106.2 502.8 104.2 502.9 102.2 502.9 100 503 97.8 503.1 95.4 503.2 93 503.3 90.4 503.3 87.7 503.4 84.9 503.5 82 A3.5 3.5 0 0 1 496.5 82Z" fill="#21BEC5"/>
73
+ <path d="M494 129.2L500 137.2L506 129.2" fill="none" stroke="#21BEC5" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
74
+ <ellipse cx="500" cy="82.0" rx="28.0" ry="3.5" fill="#BCC5CA" opacity="0.12"/>
75
+ <rect x="476" y="28" width="48" height="48" rx="9.6" fill="#212E34" stroke="#5B6B73" stroke-width="1.9"/><circle cx="500" cy="52" r="9.1" fill="#21BEC5"/>
76
+ <text x="532" y="48" class="m" font-size="14" fill="#E6EDF3" font-weight="700" text-anchor="start">adopt</text>
77
+ <text x="532" y="66" class="s" font-size="12.5" fill="#9B9D9E" text-anchor="start">adds what is missing, shows you the diff</text>
65
78
  </svg>
package/docs/skills.svg CHANGED
@@ -1,5 +1,6 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="960" height="530" viewBox="0 0 960 530" role="img" aria-labelledby="t d"><title id="t">The skills and what they hand on</title><desc id="d">/jh-grill hands decision rows to /jh-plan. /jh-plan feeds /jh-orchestrate with phases, Writes sets and a model per phase, or a single phase to /jh-implement; under /jh-orchestrate each lane runs /jh-implement. /jh-implement ends in /jh-pre-commit, which runs jh check, /jh-review, the jh-spec-review agent against the plan, and the reviewers routed by path: /jh-uncle-bob, /jh-security-audit, /jh-devops. /jh-diagnose hands a red command to /jh-implement.</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}</style></defs>
2
- <rect x="1" y="1" width="958" height="528" rx="18" fill="#FAF9F8" stroke="#C4CBCE" stroke-width="1.5"/>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="960" height="600" viewBox="0 0 960 600" role="img" aria-labelledby="t d"><title id="t">The skills and what they hand on</title><desc id="d">A plan of your own or an issue is adopted: Jack adds what is missing, shows you the diff and hands it to /jh-orchestrate. /jh-grill hands decision rows to /jh-plan. /jh-plan feeds /jh-orchestrate with phases, Writes sets and a model per phase, or a single phase to /jh-implement, which your own skill can replace; under /jh-orchestrate each lane runs /jh-implement or your own skill. /jh-implement ends in /jh-pre-commit, which runs jh check, /jh-review, the jh-spec-review agent against the plan, and the reviewers routed by path: /jh-uncle-bob, /jh-security-audit, /jh-devops. /jh-diagnose hands a red command to /jh-implement.</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}</style></defs>
2
+ <rect x="1" y="1" width="958" height="598" rx="18" fill="#FAF9F8" stroke="#C4CBCE" stroke-width="1.5"/>
3
+ <g transform="translate(0 70)">
3
4
  <path d="M120 103.5 125.3 103.4 130.4 103.3 135.4 103.3 140.3 103.2 145.1 103.1 149.8 103 154.5 103 159 102.9 163.5 102.8 168 102.7 172.5 102.6 176.9 102.6 181.3 102.5 185.8 102.4 190.2 102.3 194.7 102.2 199.2 102.2 203.9 102.1 208.5 102 213.3 101.9 218.2 101.8 223.1 101.8 228.2 101.7 233.5 101.6 238.8 101.5 244.4 101.5 250.1 101.4 256 101.3 A1.3 1.3 0 0 1 256 98.7 L256 98.7 250.1 98.6 244.4 98.5 238.8 98.5 233.5 98.4 228.2 98.3 223.1 98.2 218.2 98.2 213.3 98.1 208.5 98 203.9 97.9 199.2 97.8 194.7 97.8 190.2 97.7 185.8 97.6 181.3 97.5 176.9 97.4 172.5 97.4 168 97.3 163.5 97.2 159 97.1 154.5 97 149.8 97 145.1 96.9 140.3 96.8 135.4 96.7 130.4 96.7 125.3 96.6 120 96.5 A3.5 3.5 0 0 1 120 103.5Z" fill="#03B5BD"/>
4
5
  <path d="M249.2 106L257.2 100L249.2 94" fill="none" stroke="#03B5BD" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
5
6
  <path d="M320 103.5 325.3 103.4 330.6 103.3 335.8 103.3 340.9 103.2 346 103.1 351 103 356 103 361 102.9 366 102.8 370.9 102.7 375.9 102.6 380.8 102.6 385.8 102.5 390.8 102.4 395.7 102.3 400.8 102.2 405.8 102.2 410.9 102.1 416.1 102 421.3 101.9 426.6 101.8 431.9 101.8 437.4 101.7 442.9 101.6 448.5 101.5 454.2 101.5 460.1 101.4 466 101.3 A1.3 1.3 0 0 1 466 98.7 L466 98.7 460.1 98.6 454.2 98.5 448.5 98.5 442.9 98.4 437.4 98.3 431.9 98.2 426.6 98.2 421.3 98.1 416.1 98 410.9 97.9 405.8 97.8 400.8 97.8 395.7 97.7 390.8 97.6 385.8 97.5 380.8 97.4 375.9 97.4 370.9 97.3 366 97.2 361 97.1 356 97 351 97 346 96.9 340.9 96.8 335.8 96.7 330.6 96.7 325.3 96.6 320 96.5 A3.5 3.5 0 0 1 320 103.5Z" fill="#03B5BD"/>
@@ -44,6 +45,7 @@
44
45
  <text x="532" y="114" class="s" font-size="12.5" fill="#4A4E51" text-anchor="start">lanes in herdr</text>
45
46
  <text x="500" y="340" class="m" font-size="14" fill="#212E34" font-weight="700" text-anchor="middle">/jh-implement</text>
46
47
  <text x="500" y="358" class="s" font-size="12.5" fill="#4A4E51" text-anchor="middle">builds, ticks</text>
48
+ <text x="500" y="382" class="s" font-size="12.5" fill="#4A4E51" text-anchor="middle" font-style="italic">or your own skill</text>
47
49
  <text x="700" y="340" class="m" font-size="14" fill="#212E34" font-weight="700" text-anchor="middle">/jh-pre-commit</text>
48
50
  <text x="700" y="358" class="s" font-size="12.5" fill="#4A4E51" text-anchor="middle">jh check</text>
49
51
  <text x="880" y="200" class="m" font-size="14" fill="#212E34" font-weight="700" text-anchor="middle">/jh-review</text>
@@ -62,4 +64,15 @@
62
64
  <text x="788" y="392" class="s" font-size="12.5" fill="#4A4E51" text-anchor="end" font-style="italic">routed</text>
63
65
  <text x="788" y="408" class="s" font-size="12.5" fill="#4A4E51" text-anchor="end" font-style="italic">by path</text>
64
66
  <text x="420" y="420" class="s" font-size="12.5" fill="#4A4E51" text-anchor="start" font-style="italic">red command</text>
67
+ </g>
68
+ <text x="290" y="48" class="s" font-size="13" fill="#212E34" font-weight="600" text-anchor="middle">your own plan</text>
69
+ <text x="290" y="66" class="s" font-size="13" fill="#212E34" font-weight="600" text-anchor="middle">or an issue</text>
70
+ <path d="M350 52C400 52 440 52 466 52" fill="none" stroke="#7E8487" stroke-width="2.6" stroke-linecap="round" stroke-dasharray="0.1 7"/>
71
+ <path d="M459.2 58L467.2 52L459.2 46" fill="none" stroke="#7E8487" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
72
+ <path d="M496.5 82 496.6 84.9 496.7 87.7 496.7 90.4 496.8 93 496.9 95.4 497 97.8 497.1 100 497.1 102.2 497.2 104.2 497.3 106.2 497.4 108.1 497.4 110 497.5 111.8 497.6 113.5 497.7 115.2 497.8 116.8 497.8 118.5 497.9 120.1 498 121.6 498.1 123.2 498.1 124.8 498.2 126.3 498.3 127.9 498.4 129.4 498.5 131 498.5 132.7 498.6 134.3 498.7 136 A1.3 1.3 0 0 1 501.3 136 L501.3 136 501.4 134.3 501.5 132.7 501.5 131 501.6 129.4 501.7 127.9 501.8 126.3 501.9 124.8 501.9 123.2 502 121.6 502.1 120.1 502.2 118.5 502.2 116.8 502.3 115.2 502.4 113.5 502.5 111.8 502.6 110 502.6 108.1 502.7 106.2 502.8 104.2 502.9 102.2 502.9 100 503 97.8 503.1 95.4 503.2 93 503.3 90.4 503.3 87.7 503.4 84.9 503.5 82 A3.5 3.5 0 0 1 496.5 82Z" fill="#03B5BD"/>
73
+ <path d="M494 129.2L500 137.2L506 129.2" fill="none" stroke="#03B5BD" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
74
+ <ellipse cx="500" cy="82.0" rx="28.0" ry="3.5" fill="#BCC5CA" opacity="0.55"/>
75
+ <rect x="476" y="28" width="48" height="48" rx="9.6" fill="#212E34"/><circle cx="500" cy="52" r="9.1" fill="#03B5BD"/>
76
+ <text x="532" y="48" class="m" font-size="14" fill="#212E34" font-weight="700" text-anchor="start">adopt</text>
77
+ <text x="532" y="66" class="s" font-size="12.5" fill="#4A4E51" text-anchor="start">adds what is missing, shows you the diff</text>
65
78
  </svg>
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.13.0"
3
+ version = "1.15.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"]
@@ -7,11 +7,13 @@ type Limits = { attempts: number; intervalMs: number }
7
7
  * Measured on herdr 0.9.0: the hook starts ~85 ms after the server, `pane process-info` answers
8
8
  * `pane_not_found` until the terminals spawn at ~0.9 s, and the agents are back at ~3 s. The panes get 10 s, ten
9
9
  * times what they took; `herd --ensure` gets four tries 2 s apart, past the ~3 s of the agents, for a pane that
10
- * goes away between the two reads.
10
+ * goes away between the two reads. The coordinator gets a minute: after a crash its session was still not back after
11
+ * ~8 s, and the herd beside it stayed missing.
11
12
  */
12
13
  export const RESTORE_LIMITS = {
13
14
  panes: { attempts: 20, intervalMs: 500 },
14
15
  ensure: { attempts: 4, intervalMs: 2_000 },
16
+ coordinator: { attempts: 30, intervalMs: 2_000 },
15
17
  } as const satisfies Record<string, Limits>
16
18
 
17
19
  /** Runs `attempt` until it works or the attempts are used up, waiting the interval between two of them. */
@@ -23,6 +25,31 @@ export const retried = (attempt: () => boolean, limits: Limits, sleep: (ms: numb
23
25
  }
24
26
  }
25
27
 
28
+ /**
29
+ * Waits for every item in one shared window of `limits`, calls `onReady` for each as soon as `isReady` holds, and
30
+ * returns the items that never got ready.
31
+ */
32
+ export const awaitEach = <T>(
33
+ items: readonly T[],
34
+ isReady: (item: T) => boolean,
35
+ onReady: (item: T) => void,
36
+ limits: Limits,
37
+ sleep: (ms: number) => void,
38
+ ): T[] => {
39
+ let pending = [...items]
40
+ retried(
41
+ () => {
42
+ const ready = pending.filter(isReady)
43
+ for (const item of ready) onReady(item)
44
+ pending = pending.filter((item) => !ready.includes(item))
45
+ return pending.length === 0
46
+ },
47
+ limits,
48
+ sleep,
49
+ )
50
+ return pending
51
+ }
52
+
26
53
  /**
27
54
  * The directories of the panes an agent runs in, each once, from a `herdr pane list` answer; none when unreadable.
28
55
  * `agentPanes` are the panes of `herdr agent list`: right after a restart the pane list names no agent there for a
@@ -43,6 +70,10 @@ export const agentDirectoriesOf = (json: string, agentPanes: readonly string[]):
43
70
  }
44
71
  }
45
72
 
73
+ /** Whether an agent runs in `root` itself, where the coordinator works, rather than only in its lanes' worktrees. */
74
+ export const hasAgentIn = (root: string, paneList: string, agentPanes: readonly string[]): boolean =>
75
+ agentDirectoriesOf(paneList, agentPanes).includes(root)
76
+
46
77
  /** The arguments for `bun` that run `herd --ensure` with the CLI of the package this plugin ships in. */
47
78
  export const ensureArguments = (pluginRoot: string): string[] => [
48
79
  join(dirname(pluginRoot), 'src', 'cli.ts'),
@@ -80,13 +111,33 @@ const panesAnswer = (panes: readonly string[]): boolean =>
80
111
  const ensureIn = (root: string, pluginRoot: string): boolean =>
81
112
  spawnSync('bun', ensureArguments(pluginRoot), { cwd: root, stdio: 'inherit' }).status === 0
82
113
 
114
+ const coordinatorBackIn = (root: string): boolean =>
115
+ hasAgentIn(root, herdrRun(['pane', 'list']).stdout ?? '', idsOf(herdrRun(['agent', 'list']).stdout ?? '', 'agents'))
116
+
117
+ const ensureWithRetries = (root: string, sleep: (ms: number) => void) => {
118
+ if (!retried(() => ensureIn(root, import.meta.dir), RESTORE_LIMITS.ensure, sleep))
119
+ console.error(`restore-plans: herd --ensure failed in ${root}`)
120
+ }
121
+
122
+ /**
123
+ * The herd beside each coordinator once its session is back, which can take longer than the lanes'. A coordinator
124
+ * already back had its herd from the first `--ensure`; the others share one waiting window.
125
+ */
126
+ const ensureCoordinators = (roots: readonly string[], sleep: (ms: number) => void) => {
127
+ const missing = roots.filter((root) => !coordinatorBackIn(root))
128
+ const ensure = (root: string) => ensureWithRetries(root, sleep)
129
+ for (const root of awaitEach(missing, coordinatorBackIn, ensure, RESTORE_LIMITS.coordinator, sleep))
130
+ console.error(`restore-plans: no coordinator came back in ${root}`)
131
+ }
132
+
83
133
  /**
84
134
  * herdr's `[[startup]]` hook: after a server start or a live handoff, brings back the herd panes of every
85
135
  * repository an agent runs in. It parses herdr's answers itself and never throws, unlike the strict parsers of
86
136
  * `src/plans/plan-panes.ts`: a hook that fails leaves nothing behind but a log line, and `herd --ensure`, which it
87
137
  * calls, reads the panes again strictly. It reads the panes at once, while herdr still names their agents, then waits until
88
- * herdr answers for every pane and retries `herd --ensure` within `RESTORE_LIMITS`. A repository without a board
89
- * or config only prints why and is skipped.
138
+ * herdr answers for every pane and retries `herd --ensure` within `RESTORE_LIMITS`. A repository whose coordinator
139
+ * is not back yet gets one more `herd --ensure` once an agent runs in its main checkout. A repository without a
140
+ * board or config only prints why and is skipped.
90
141
  */
91
142
  const main = (): number => {
92
143
  const list = herdrRun(['pane', 'list'])
@@ -98,9 +149,8 @@ const main = (): number => {
98
149
  const sleep = (ms: number) => Bun.sleepSync(ms)
99
150
  if (!retried(() => panesAnswer(idsOf(listing, 'panes')), RESTORE_LIMITS.panes, sleep))
100
151
  console.error('restore-plans: herdr did not answer for every pane in time, trying anyway')
101
- for (const root of roots)
102
- if (!retried(() => ensureIn(root, import.meta.dir), RESTORE_LIMITS.ensure, sleep))
103
- console.error(`restore-plans: herd --ensure failed in ${root}`)
152
+ for (const root of roots) ensureWithRetries(root, sleep)
153
+ ensureCoordinators([...roots], sleep)
104
154
  return 0
105
155
  }
106
156
 
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.13.0}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.15.0}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -39,8 +39,11 @@ const sectionsAreObjects = (config: Record<string, unknown>): boolean =>
39
39
  isSectionOrAbsent(sectionAt(config, 'guard'), 'planApproval') &&
40
40
  isSectionOrAbsent(config, 'board')
41
41
 
42
- /** `guard.requiredChecks`, else where configs before 1.10.0 kept it, `guard.planApproval.requiredChecks`. */
43
- const requiredChecksOf = (guard: Record<string, unknown>) =>
42
+ /**
43
+ * `guard.requiredChecks`, else where configs before 1.10.0 kept it, `guard.planApproval.requiredChecks`: `{ value }`,
44
+ * with `value` undefined when neither is set, and `undefined` for a list that does not read.
45
+ */
46
+ export const requiredChecksOf = (guard: Record<string, unknown>): { value: readonly string[] | undefined } | undefined =>
44
47
  'requiredChecks' in guard
45
48
  ? optionalValue(guard, 'requiredChecks', isCheckList)
46
49
  : optionalValue(sectionAt(guard, 'planApproval'), 'requiredChecks', isCheckList)
@@ -13,6 +13,7 @@ type PullRequestView = {
13
13
  headRefOid?: unknown
14
14
  baseRefName?: unknown
15
15
  isCrossRepository?: unknown
16
+ isDraft?: unknown
16
17
  state?: unknown
17
18
  statusCheckRollup?: unknown
18
19
  mergeable?: unknown
@@ -20,10 +21,17 @@ type PullRequestView = {
20
21
  changedFiles?: unknown
21
22
  }
22
23
 
24
+ /**
25
+ * A check of the rollup: `green` when it does not stand in a merge's way, `succeeded` only for a check run that
26
+ * concluded success; a status never succeeds here.
27
+ */
28
+ type RollupCheck = { name: string; green: boolean; succeeded: boolean }
29
+
23
30
  /** Where a pull request lives and how many files it changes, as GitHub's `pr view` says. */
24
31
  export type RepositoryFacts = { repository: string; changedFiles: number }
25
32
 
26
- const VIEW_FIELDS = 'headRefName,headRefOid,baseRefName,isCrossRepository,state,url,changedFiles,statusCheckRollup,mergeable'
33
+ const VIEW_FIELDS =
34
+ 'headRefName,headRefOid,baseRefName,isCrossRepository,isDraft,state,url,changedFiles,statusCheckRollup,mergeable'
27
35
  const PULL_REQUEST_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/pull\/[1-9]\d*$/
28
36
  /** The conclusions of a finished check run that do not stand in a merge's way. */
29
37
  const GREEN_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
@@ -31,24 +39,27 @@ const GREEN_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
31
39
  const viewOf = (json: string): PullRequestView | undefined => parseJsonObject(json)
32
40
 
33
41
  /**
34
- * A check of the rollup by its name, whether it is green, and whether it is a check run: a run comes from a workflow
35
- * or an app, while a status anyone with push access can set through the API.
42
+ * A check of the rollup by its name, whether it is green, and whether it succeeded as a check run: a run comes from a
43
+ * workflow or an app, while a status anyone with push access can set through the API.
36
44
  */
37
- const checkOf = (entry: unknown): { name: string; green: boolean; run: boolean } | undefined => {
45
+ const checkOf = (entry: unknown): RollupCheck | undefined => {
38
46
  if (typeof entry !== 'object' || entry === null) return undefined
39
47
  const { __typename, name, status, conclusion, context, state } = entry as Record<string, unknown>
40
48
  if (__typename === 'CheckRun' && typeof name === 'string') {
41
- const green = status === 'COMPLETED' && typeof conclusion === 'string' && GREEN_CONCLUSIONS.has(conclusion)
42
- return { name, green, run: true }
49
+ const finished = status === 'COMPLETED' && typeof conclusion === 'string'
50
+ return { name, green: finished && GREEN_CONCLUSIONS.has(conclusion), succeeded: finished && conclusion === 'SUCCESS' }
43
51
  }
44
- if (__typename === 'StatusContext' && typeof context === 'string') return { name: context, green: state === 'SUCCESS', run: false }
52
+ if (__typename === 'StatusContext' && typeof context === 'string') return { name: context, green: state === 'SUCCESS', succeeded: false }
45
53
  return undefined
46
54
  }
47
55
 
48
- /** The head branch of an open pull request from this repository; a fork's branch name proves nothing. */
56
+ /**
57
+ * The head branch of an open, ready pull request from this repository; a fork's branch name proves nothing, and a
58
+ * draft is never merged, nor one whose draft flag GitHub did not answer.
59
+ */
49
60
  export const parseHeadBranch = (json: string): string | undefined => {
50
- const { headRefName, isCrossRepository, state } = viewOf(json) ?? {}
51
- const ours = state === 'OPEN' && isCrossRepository === false
61
+ const { headRefName, isCrossRepository, isDraft, state } = viewOf(json) ?? {}
62
+ const ours = state === 'OPEN' && isCrossRepository === false && isDraft === false
52
63
  return ours && typeof headRefName === 'string' ? headRefName : undefined
53
64
  }
54
65
 
@@ -60,9 +71,11 @@ export const parseBaseBranch = (json: string): string | undefined => {
60
71
 
61
72
  /**
62
73
  * Whether the head commit's checks let a merge pass without a question: GitHub can merge it, it has at least one check
63
- * run, every check is green (a run finished as success, neutral or skipped, a status succeeded), and every required
64
- * check is among the runs. An empty list is what a pull request shows right after a push, before any workflow started,
65
- * so it does not pass; neither does a list of statuses alone, which an agent could set itself in that moment.
74
+ * run that succeeded, every check is green (a run finished as success, neutral or skipped, a status succeeded), and
75
+ * every required check has a run that succeeded. An empty list is what a pull request shows right after a push, before
76
+ * any workflow started, so it does not pass; neither does a list of statuses alone, which an agent could set itself in
77
+ * that moment, nor skipped runs alone or a required check that was only skipped, as a draft's runs stand right after
78
+ * `gh pr ready`.
66
79
  */
67
80
  export const checksPassed = (json: string, requiredChecks: readonly string[] | undefined): boolean => {
68
81
  const view = viewOf(json)
@@ -70,8 +83,8 @@ export const checksPassed = (json: string, requiredChecks: readonly string[] | u
70
83
  if (view?.mergeable !== 'MERGEABLE' || !Array.isArray(rollup)) return false
71
84
  const checks = rollup.map(checkOf)
72
85
  if (!checks.every((check) => check?.green === true)) return false
73
- const runs = new Set(checks.flatMap((check) => (check?.run ? [check.name] : [])))
74
- return runs.size > 0 && (requiredChecks ?? []).every((name) => runs.has(name))
86
+ const succeeded = new Set(checks.flatMap((check) => (check?.succeeded ? [check.name] : [])))
87
+ return succeeded.size > 0 && (requiredChecks ?? []).every((name) => succeeded.has(name))
75
88
  }
76
89
 
77
90
  /** The commit the pull request's head points at, as GitHub says now; what a `--match-head-commit` must name. */
@@ -41,6 +41,8 @@ type StandingApproval = (facts: MergeFacts) => boolean | MergeNote
41
41
  /** The ways a merge may pass without a question. */
42
42
  type MergeApprovals = {
43
43
  branches: ReadonlySet<string>
44
+ /** The checks a self-merge of an approved branch waits for a successful run of. */
45
+ requiredChecks: readonly string[] | undefined
44
46
  withoutChecks: readonly string[]
45
47
  /** The paths that always ask, which a `withoutChecks` pattern never opens. */
46
48
  alwaysAsk: readonly string[]
@@ -64,13 +66,13 @@ const approvedBranchesFrom = (entries: unknown[], settings: GuardSettings, label
64
66
  * is not merged unchecked. A standing approval that does not cover a file answers with a note for the question.
65
67
  */
66
68
  const approvesMerge = (
67
- { branches, withoutChecks, alwaysAsk, standing }: MergeApprovals,
69
+ { branches, requiredChecks, withoutChecks, alwaysAsk, standing }: MergeApprovals,
68
70
  merge: PlainMerge,
69
71
  facts: MergeFacts,
70
72
  ): boolean | MergeNote => {
71
73
  const { headCommit } = facts
72
74
  if (headCommit === undefined || merge.headCommit !== headCommit) return false
73
- if (branches.has(facts.branch) && checksPassed(facts.view, undefined)) return true
75
+ if (branches.has(facts.branch) && checksPassed(facts.view, requiredChecks)) return true
74
76
  if (withoutChecks.length > 0 && isCheckFree(facts.files() ?? [], withoutChecks, alwaysAsk)) return true
75
77
  return standing === undefined ? false : standing(facts)
76
78
  }
@@ -143,12 +145,13 @@ const policyFor = (input: HookInput, labels: ApprovalLabels, projectDirectory: s
143
145
  if (!isWithinDirectory(directory, projectDirectory)) return false
144
146
  const approvals = {
145
147
  branches: branches(),
148
+ requiredChecks: settings.requiredChecks,
146
149
  withoutChecks: settings.withoutChecks,
147
150
  alwaysAsk: [...ALWAYS_ASK_PATHS, ...settings.alwaysAsk],
148
151
  standing: standing(),
149
152
  }
150
153
  if (approvals.branches.size === 0 && approvals.withoutChecks.length === 0 && approvals.standing === undefined) return false
151
- // The branch lookup also proves the pull request is open and from this repository; every path needs that.
154
+ // The branch lookup also proves the pull request is open, no draft and from this repository; every path needs that.
152
155
  const facts = mergeFactsOf(merge.pullRequest, viewPullRequest(merge.pullRequest, directory) ?? '', directory)
153
156
  return facts !== undefined && approvesMerge(approvals, merge, facts)
154
157
  },
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { isRecord } from '../shared/json'
5
+ import { requiredChecksOf } from './board-settings'
5
6
 
6
7
  export type GuardSettings = {
7
8
  commands: string[]
@@ -13,6 +14,11 @@ export type GuardSettings = {
13
14
  * before 1.10.0); they only ever add a question.
14
15
  */
15
16
  alwaysAsk: string[]
17
+ /**
18
+ * `guard.requiredChecks`: the checks a self-merge waits for a successful run of; undefined: none named, or a list
19
+ * that does not read, which turns `selfMerge.offer` off.
20
+ */
21
+ requiredChecks: readonly string[] | undefined
16
22
  }
17
23
 
18
24
  export const DEFAULT_SETTINGS: GuardSettings = {
@@ -20,6 +26,7 @@ export const DEFAULT_SETTINGS: GuardSettings = {
20
26
  selfMerge: { offer: true, approvalMinutes: 180 },
21
27
  withoutChecks: [],
22
28
  alwaysAsk: [],
29
+ requiredChecks: undefined,
23
30
  }
24
31
 
25
32
  const CONFIG_PATH = join('.jackherd', 'config.json')
@@ -63,6 +70,7 @@ const pathsOf = (value: unknown): string[] =>
63
70
  /**
64
71
  * The guard settings of the project's `.jackherd/config.json`, else of the home one. Whatever is missing or
65
72
  * malformed keeps its default, so a broken config guards more, never less; only an empty list switches the guard off.
73
+ * A `requiredChecks` list that does not read has no default that guards more, so it turns the self-merge answer off.
66
74
  */
67
75
  export const readGuardSettings = (projectDirectory: string, home: string = homedir()): GuardSettings => {
68
76
  const config = readConfig([projectDirectory, home])
@@ -70,15 +78,18 @@ export const readGuardSettings = (projectDirectory: string, home: string = homed
70
78
  const merge = isRecord(config.merge) ? config.merge : {}
71
79
  const selfMerge = isRecord(section.selfMerge) ? section.selfMerge : {}
72
80
  const legacy = isRecord(section.planApproval) ? section.planApproval : {}
81
+ const readableRequiredChecks = requiredChecksOf(section)
82
+ const offer = typeof selfMerge.offer === 'boolean' ? selfMerge.offer : DEFAULT_SETTINGS.selfMerge.offer
73
83
  return {
74
84
  commands: isCommandList(section.commands) ? section.commands : DEFAULT_SETTINGS.commands,
75
85
  selfMerge: {
76
- offer: typeof selfMerge.offer === 'boolean' ? selfMerge.offer : DEFAULT_SETTINGS.selfMerge.offer,
86
+ offer: offer && readableRequiredChecks !== undefined,
77
87
  approvalMinutes: isMinutes(selfMerge.approvalMinutes)
78
88
  ? selfMerge.approvalMinutes
79
89
  : DEFAULT_SETTINGS.selfMerge.approvalMinutes,
80
90
  },
81
91
  withoutChecks: isPatternList(merge.withoutChecks) ? merge.withoutChecks : DEFAULT_SETTINGS.withoutChecks,
82
92
  alwaysAsk: [...pathsOf(section.alwaysAsk), ...pathsOf(legacy.exceptPaths)],
93
+ requiredChecks: readableRequiredChecks?.value,
83
94
  }
84
95
  }
@@ -1,11 +1,8 @@
1
1
  import { parseTranscript } from '../shared/dialog-answers'
2
2
  import { isRecord, type JsonObject } from '../shared/json'
3
+ import { CONTENT_BLOCK_TYPE, LOG_ENTRY_TYPE } from '../shared/log-vocabulary'
3
4
  import { familyOf, type ModelFamily } from '../shared/model-family'
4
5
 
5
- const USER_ENTRY = 'user'
6
- const ASSISTANT_ENTRY = 'assistant'
7
- const TEXT_BLOCK = 'text'
8
-
9
6
  const messageOf = (entry: unknown): JsonObject | null =>
10
7
  isRecord(entry) && isRecord(entry.message) ? entry.message : null
11
8
 
@@ -13,14 +10,14 @@ const textOf = (content: unknown): string | null => {
13
10
  if (typeof content === 'string') return content
14
11
  if (!Array.isArray(content)) return null
15
12
  const texts = content.flatMap((block) =>
16
- isRecord(block) && block.type === TEXT_BLOCK && typeof block.text === 'string' ? [block.text] : [],
13
+ isRecord(block) && block.type === CONTENT_BLOCK_TYPE.text && typeof block.text === 'string' ? [block.text] : [],
17
14
  )
18
15
  return texts.length > 0 ? texts.join('\n') : null
19
16
  }
20
17
 
21
18
  /** The subagent's prompt: the text of the first user entry of its transcript; null without one. */
22
19
  export const promptOf = (head: string): string | null => {
23
- const first = parseTranscript(head).find((entry) => isRecord(entry) && entry.type === USER_ENTRY)
20
+ const first = parseTranscript(head).find((entry) => isRecord(entry) && entry.type === LOG_ENTRY_TYPE.user)
24
21
  return textOf(messageOf(first)?.content)
25
22
  }
26
23
 
@@ -28,7 +25,7 @@ export const promptOf = (head: string): string | null => {
28
25
  export type AnsweredModel = { model: string; family: ModelFamily }
29
26
 
30
27
  const answeredModel = (entry: unknown): AnsweredModel[] => {
31
- const model = isRecord(entry) && entry.type === ASSISTANT_ENTRY ? messageOf(entry)?.model : undefined
28
+ const model = isRecord(entry) && entry.type === LOG_ENTRY_TYPE.assistant ? messageOf(entry)?.model : undefined
32
29
  const family = typeof model === 'string' ? familyOf(model) : null
33
30
  return typeof model === 'string' && family ? [{ model, family }] : []
34
31
  }
@@ -1,5 +1,6 @@
1
1
  import { ASK_USER_QUESTION_TOOL, contentOf, dialogAnswers, questionsOf } from '../shared/dialog-answers'
2
2
  import { isRecord, type JsonObject } from '../shared/json'
3
+ import { CONTENT_BLOCK_TYPE, LOG_ENTRY_TYPE } from '../shared/log-vocabulary'
3
4
 
4
5
  /**
5
6
  * A question of a dialog that ended without an answer, as the hook records it: what was asked, when, from where and
@@ -50,7 +51,7 @@ const asksOf = (entries: readonly JsonObject[]): Ask[] =>
50
51
  const askedAt = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
51
52
  if (Number.isNaN(askedAt)) return []
52
53
  return contentOf(entry)
53
- .filter((block) => block.type === 'tool_use' && block.name === ASK_USER_QUESTION_TOOL)
54
+ .filter((block) => block.type === CONTENT_BLOCK_TYPE.toolUse && block.name === ASK_USER_QUESTION_TOOL)
54
55
  .flatMap((block) =>
55
56
  typeof block.id === 'string'
56
57
  ? questionsOf(block.input).map(({ question }) => ({
@@ -70,7 +71,7 @@ const resultsOf = (entries: readonly JsonObject[]): Map<string, JsonObject | nul
70
71
  for (const entry of entries) {
71
72
  const answers = isRecord(entry.toolUseResult) && isRecord(entry.toolUseResult.answers) ? entry.toolUseResult.answers : null
72
73
  for (const block of contentOf(entry))
73
- if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') results.set(block.tool_use_id, answers)
74
+ if (block.type === CONTENT_BLOCK_TYPE.toolResult &&typeof block.tool_use_id === 'string') results.set(block.tool_use_id, answers)
74
75
  }
75
76
  return results
76
77
  }
@@ -79,7 +80,7 @@ const resultsOf = (entries: readonly JsonObject[]): Map<string, JsonObject | nul
79
80
  const wentOnAfter = (ask: Ask, entries: readonly JsonObject[]): boolean =>
80
81
  entries
81
82
  .slice(ask.position + 1)
82
- .some((entry) => (entry.type === 'user' || entry.type === 'assistant') && messageIdOf(entry) !== ask.messageId)
83
+ .some((entry) => (entry.type === LOG_ENTRY_TYPE.user || entry.type === LOG_ENTRY_TYPE.assistant) && messageIdOf(entry) !== ask.messageId)
83
84
 
84
85
  /** No answer came back, the answer is not a text for this question, or it only puts the question off. */
85
86
  const endedUnanswered = (ask: Ask, results: ReadonlyMap<string, JsonObject | null>, entries: readonly JsonObject[]) => {
@@ -5,11 +5,12 @@
5
5
  */
6
6
  import type { DialogAnswer } from './dialog-answer'
7
7
  import { isRecord, type JsonObject } from './json'
8
+ import { CLAUDE_TOOL, CONTENT_BLOCK_TYPE } from './log-vocabulary'
8
9
 
9
10
  export type { DialogAnswer } from './dialog-answer'
10
11
 
11
12
  /** The tool name of the dialog, as hook inputs and transcripts carry it. */
12
- export const ASK_USER_QUESTION_TOOL = 'AskUserQuestion'
13
+ export const ASK_USER_QUESTION_TOOL = CLAUDE_TOOL.askUserQuestion
13
14
 
14
15
  /** The content blocks of a transcript entry's message; none when it has no list of blocks. */
15
16
  export const contentOf = (entry: JsonObject): JsonObject[] => {
@@ -52,7 +53,10 @@ const askedQuestions = (entries: JsonObject[]): Map<string, AskedQuestion[]> =>
52
53
  entries
53
54
  .filter(isMainThread)
54
55
  .flatMap(contentOf)
55
- .filter((block) => block.type === 'tool_use' && block.name === ASK_USER_QUESTION_TOOL && typeof block.id === 'string')
56
+ .filter(
57
+ (block) =>
58
+ block.type === CONTENT_BLOCK_TYPE.toolUse && block.name === ASK_USER_QUESTION_TOOL && typeof block.id === 'string',
59
+ )
56
60
  .map((block): [string, AskedQuestion[]] => [block.id as string, questionsOf(block.input)]),
57
61
  )
58
62
 
@@ -61,7 +65,7 @@ const answersIn = (entry: JsonObject, asked: Map<string, AskedQuestion[]>): Dial
61
65
  const answeredAt = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
62
66
  if (!isRecord(answers) || Number.isNaN(answeredAt)) return []
63
67
  return contentOf(entry)
64
- .filter((block) => block.type === 'tool_result')
68
+ .filter((block) => block.type === CONTENT_BLOCK_TYPE.toolResult)
65
69
  .flatMap((block) => asked.get(String(block.tool_use_id)) ?? [])
66
70
  .flatMap(({ question, offered }) => {
67
71
  const answer = answers[question]
@@ -0,0 +1,13 @@
1
+ /** The entry types of a Claude Code transcript line that a hook tells apart; the transcript delivers them as strings. */
2
+ export const LOG_ENTRY_TYPE = { user: 'user', assistant: 'assistant' } as const
3
+
4
+ /** The block types inside an entry's `message.content`. */
5
+ export const CONTENT_BLOCK_TYPE = { text: 'text', toolUse: 'tool_use', toolResult: 'tool_result' } as const
6
+
7
+ /** The names of Claude Code's tools a hook looks for, in a `tool_use` block or a hook input's `tool_name`. */
8
+ export const CLAUDE_TOOL = {
9
+ askUserQuestion: 'AskUserQuestion',
10
+ agent: 'Agent',
11
+ taskUpdate: 'TaskUpdate',
12
+ todoWrite: 'TodoWrite',
13
+ } as const
@@ -1,4 +1,5 @@
1
1
  import { isRecord } from './json'
2
+ import { CONTENT_BLOCK_TYPE, LOG_ENTRY_TYPE } from './log-vocabulary'
2
3
 
3
4
  export type Turn = { firstPrompt: string; tools: string[] }
4
5
 
@@ -37,17 +38,19 @@ const blocks = (entry: Entry): Block[] => {
37
38
 
38
39
  /** The text a person typed: a string, or text parts. A tool result and the harness's own skill text are no prompt. */
39
40
  const promptText = (entry: Entry): string | undefined => {
40
- if (entry.type !== 'user' || entry.isMeta === true) return undefined
41
+ if (entry.type !== LOG_ENTRY_TYPE.user || entry.isMeta === true) return undefined
41
42
  const content = entry.message?.content
42
43
  if (typeof content === 'string') return content
43
- const texts = blocks(entry).flatMap((block) => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : []))
44
+ const texts = blocks(entry).flatMap((block) =>
45
+ block.type === CONTENT_BLOCK_TYPE.text && typeof block.text === 'string' ? [block.text] : [],
46
+ )
44
47
  return texts.length > 0 ? texts.join('\n') : undefined
45
48
  }
46
49
 
47
50
  const toolUses = (entry: Entry): ToolUse[] =>
48
- entry.type === 'assistant'
51
+ entry.type === LOG_ENTRY_TYPE.assistant
49
52
  ? blocks(entry).flatMap((block) =>
50
- block.type === 'tool_use' && typeof block.name === 'string'
53
+ block.type === CONTENT_BLOCK_TYPE.toolUse && typeof block.name === 'string'
51
54
  ? [{ name: block.name, input: isRecord(block.input) ? block.input : {} }]
52
55
  : [],
53
56
  )
@@ -1,4 +1,5 @@
1
1
  import { isRecord } from '../shared/json'
2
+ import { CLAUDE_TOOL } from '../shared/log-vocabulary'
2
3
  import { toolUsesOf } from '../shared/transcript'
3
4
 
4
5
  /**
@@ -41,8 +42,8 @@ export const hasTaskInProgress = (lines: readonly string[]): boolean => {
41
42
  let todos = false
42
43
  const tasks = new Map<string, boolean>()
43
44
  for (const { name, input } of toolUsesOf(lines.join('\n'))) {
44
- if (name === 'TodoWrite') todos = todosInProgress(input)
45
- if (name === 'TaskUpdate' && input.taskId !== undefined && input.status !== undefined)
45
+ if (name === CLAUDE_TOOL.todoWrite) todos = todosInProgress(input)
46
+ if (name === CLAUDE_TOOL.taskUpdate &&input.taskId !== undefined && input.status !== undefined)
46
47
  tasks.set(String(input.taskId), input.status === IN_PROGRESS)
47
48
  }
48
49
  return todos || [...tasks.values()].some(Boolean)
@@ -1,14 +1,12 @@
1
1
  import { isHintOn } from '../shared/config'
2
2
  import { isWithinDirectory } from '../shared/files'
3
+ import { CLAUDE_TOOL } from '../shared/log-vocabulary'
3
4
  import { firstPromptOf, isLanePrompt } from '../shared/transcript'
4
5
  import { decideTaskHint, hasTaskInProgress, TASK_HINT_SUBAGENT_TEXT, TASK_HINT_TEXT } from './decision'
5
6
 
6
7
  /** An Edit, Write or Agent call as the hook receives it: the session, its directory, its transcript and the tool. */
7
8
  export type TaskHintCall = { session: string; cwd: string; transcriptPath: string; tool: string }
8
9
 
9
- /** The tool that starts a subagent. */
10
- const SUBAGENT_TOOL = 'Agent'
11
-
12
10
  /** Everything with a side effect, passed in so a test reaches the flow without git, files or a home directory. */
13
11
  export type TaskHintDependencies = {
14
12
  mainCheckoutOf: (cwd: string) => string
@@ -34,7 +32,7 @@ const hintFor = (call: TaskHintCall, dependencies: TaskHintDependencies): string
34
32
  const main = dependencies.mainCheckoutOf(call.cwd)
35
33
  const worktree = dependencies.repositoryRootOf(call.cwd)
36
34
  const hinted = dependencies.readMark(main, call.session)
37
- const subagent = call.tool === SUBAGENT_TOOL
35
+ const subagent = call.tool === CLAUDE_TOOL.agent
38
36
  const decision = decideTaskHint({
39
37
  enabled: isHintOn(dependencies.configs(main), 'taskSignal'),
40
38
  lane: isLanePrompt(firstPromptOf(dependencies.readHead(call.transcriptPath).join('\n'))),
@@ -8,6 +8,7 @@ How an agent opens a pull request so every PR reads consistently and is review-
8
8
  - **Base = `{{kit.baseBranch}}`** unless the user names another.
9
9
  - **Work on a branch.** If the working branch is `{{kit.baseBranch}}`, branch first.
10
10
  - **Push before creating, with the user's go-ahead for the push.** The head branch must be on the remote so `gh pr create` has a target.
11
+ - **Drafts:** Opening as a draft is **{{kit.merge.openAsDraft}}** (`merge.openAsDraft`); `on` opens every PR with `gh pr create --draft`; you turn it ready (`gh pr ready <n>`) once it goes to merge.
11
12
  - **Check for an existing PR first** (`gh pr view <branch>`). If a prior PR for the same branch is already **merged**, the new one is a continuation — mark it in the title.
12
13
 
13
14
  ## Title