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.
- package/CHANGELOG.md +20 -0
- package/README.md +28 -38
- package/assets/icons/skills.svg +1 -0
- package/dist/config.d.ts +12 -1
- package/dist/config.js +35 -2
- package/dist/plan-format.d.ts +24 -1
- package/dist/plan-format.js +25 -5
- package/docs/plan-contract.md +84 -0
- package/docs/skills-dark.svg +15 -2
- package/docs/skills.svg +15 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/herdr-plugin/restore-plans.ts +56 -6
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
- package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
- package/kit/hooks/hooks/merge-guard/run.ts +6 -3
- package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
- package/kit/hooks/hooks/model-log/transcript.ts +4 -7
- package/kit/hooks/hooks/open-question/record.ts +4 -3
- package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
- package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
- package/kit/hooks/hooks/shared/transcript.ts +7 -4
- package/kit/hooks/hooks/task-hint/decision.ts +3 -2
- package/kit/hooks/hooks/task-hint/run.ts +2 -4
- package/kit/rules/rules/kit/pull-requests.md +1 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +24 -10
- package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
- package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
- package/kit/workflow/skills/orchestrate/references/events.md +6 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +7 -6
- package/kit/workflow/skills/start/SKILL.md +6 -2
- package/kit/workflow/skills/start/references/missing-commands.md +17 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +43 -0
- package/src/check/command.ts +53 -7
- package/src/check/file-kinds.ts +144 -0
- package/src/check/parallel-report.ts +10 -3
- package/src/check/plan.ts +118 -16
- package/src/check/report.ts +44 -1
- package/src/check/roots.ts +9 -0
- package/src/check/schema.ts +12 -0
- package/src/check/settings.ts +11 -3
- package/src/check/skill-budget-defaults.ts +21 -7
- package/src/check/skill-budgets.ts +49 -19
- package/src/claude/paths.ts +12 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +27 -0
- package/src/context/fill.ts +2 -1
- package/src/context/log-vocabulary.ts +22 -0
- package/src/context/session-log.ts +2 -1
- package/src/lane-prompt/build.ts +10 -5
- package/src/lane-prompt/command.ts +6 -1
- package/src/lane-prompt/input.ts +4 -4
- package/src/lane-prompt/texts.ts +18 -10
- package/src/plan-contract/command.ts +69 -0
- package/src/plan-contract/contract.ts +144 -0
- package/src/plan-format/dependencies.ts +1 -4
- package/src/plan-format/index.ts +4 -0
- package/src/plan-format/lanes.ts +11 -0
- package/src/plan-format/markdown.ts +6 -5
- package/src/plan-format/model-table.ts +27 -0
- package/src/plans/activity-read.ts +98 -0
- package/src/plans/ask-coordinator.ts +20 -11
- package/src/plans/lane-plans.ts +2 -1
- package/src/plans/layout.ts +49 -4
- package/src/plans/live.ts +17 -4
- package/src/plans/plan-panes.ts +25 -5
- package/src/plans/snapshot.ts +18 -8
- package/src/plans/subagents-read.ts +4 -3
- package/src/plans/subagents.ts +2 -1
- package/src/plans/texts.ts +27 -1
- package/src/settled/send.ts +80 -4
- package/src/shared/parse-json.ts +8 -0
- package/src/sync/command.ts +17 -10
- package/src/sync/foreign-skills.ts +40 -0
- package/src/sync/global-kit.ts +31 -21
- package/src/sync/init.ts +2 -2
- package/src/sync/kit-exclude.ts +39 -0
- package/src/sync/plan-file.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/report.ts +37 -2
- package/src/sync/sync-result.ts +5 -2
- package/src/sync/sync.ts +13 -5
- package/src/task-signals/claude-signals.ts +6 -5
- package/src/watch/command.ts +88 -18
- package/src/watch/lane-message.ts +1 -1
- package/src/watch/lane-progress.ts +57 -3
- package/src/watch/lane-report.ts +67 -2
- package/src/watch/lane-watch.ts +2 -8
- package/src/watch/plan-missing-read.ts +47 -0
- package/src/watch/plan-missing.ts +43 -0
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/task-parallel-touched.ts +9 -2
- package/src/watch/texts.ts +8 -0
- package/src/watch/watch-events.ts +7 -0
package/docs/skills-dark.svg
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="
|
|
2
|
-
<rect x="1" y="1" width="958" height="
|
|
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="
|
|
2
|
-
<rect x="1" y="1" width="958" height="
|
|
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>
|
|
@@ -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
|
|
89
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
43
|
-
|
|
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 =
|
|
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
|
|
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):
|
|
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
|
|
42
|
-
return { name, green,
|
|
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',
|
|
52
|
+
if (__typename === 'StatusContext' && typeof context === 'string') return { name: context, green: state === 'SUCCESS', succeeded: false }
|
|
45
53
|
return undefined
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
/**
|
|
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
|
|
64
|
-
* check
|
|
65
|
-
* so it does not pass; neither does a list of statuses alone, which an agent could set itself in
|
|
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
|
|
74
|
-
return
|
|
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,
|
|
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:
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 =
|
|
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(
|
|
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 ===
|
|
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 !==
|
|
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) =>
|
|
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 ===
|
|
51
|
+
entry.type === LOG_ENTRY_TYPE.assistant
|
|
49
52
|
? blocks(entry).flatMap((block) =>
|
|
50
|
-
block.type ===
|
|
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 ===
|
|
45
|
-
if (name ===
|
|
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 ===
|
|
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
|