jackherd 1.14.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 (83) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +27 -19
  3. package/assets/icons/skills.svg +1 -0
  4. package/dist/config.d.ts +9 -1
  5. package/dist/config.js +24 -1
  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 +22 -9
  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/plan-commit.md +5 -5
  30. package/package.json +1 -1
  31. package/schemas/jackherd.schema.json +19 -0
  32. package/src/check/command.ts +1 -1
  33. package/src/check/file-kinds.ts +10 -8
  34. package/src/check/parallel-report.ts +10 -3
  35. package/src/check/settings.ts +4 -0
  36. package/src/check/skill-budget-defaults.ts +17 -5
  37. package/src/check/skill-budgets.ts +49 -19
  38. package/src/claude/paths.ts +12 -0
  39. package/src/cli.ts +1 -0
  40. package/src/config/schema.ts +27 -0
  41. package/src/context/fill.ts +2 -1
  42. package/src/context/log-vocabulary.ts +22 -0
  43. package/src/context/session-log.ts +2 -1
  44. package/src/lane-prompt/build.ts +10 -5
  45. package/src/lane-prompt/command.ts +6 -1
  46. package/src/lane-prompt/input.ts +4 -4
  47. package/src/lane-prompt/texts.ts +18 -10
  48. package/src/plan-contract/command.ts +69 -0
  49. package/src/plan-contract/contract.ts +144 -0
  50. package/src/plan-format/dependencies.ts +1 -4
  51. package/src/plan-format/index.ts +4 -0
  52. package/src/plan-format/lanes.ts +11 -0
  53. package/src/plan-format/markdown.ts +6 -5
  54. package/src/plan-format/model-table.ts +27 -0
  55. package/src/plans/activity-read.ts +98 -0
  56. package/src/plans/ask-coordinator.ts +20 -11
  57. package/src/plans/layout.ts +49 -4
  58. package/src/plans/live.ts +17 -4
  59. package/src/plans/plan-panes.ts +25 -5
  60. package/src/plans/snapshot.ts +18 -8
  61. package/src/plans/subagents-read.ts +4 -3
  62. package/src/plans/subagents.ts +2 -1
  63. package/src/plans/texts.ts +27 -1
  64. package/src/settled/send.ts +80 -4
  65. package/src/shared/parse-json.ts +8 -0
  66. package/src/sync/command.ts +17 -10
  67. package/src/sync/foreign-skills.ts +40 -0
  68. package/src/sync/global-kit.ts +31 -21
  69. package/src/sync/init.ts +2 -2
  70. package/src/sync/kit-exclude.ts +39 -0
  71. package/src/sync/plan-file.ts +1 -1
  72. package/src/sync/render.ts +2 -1
  73. package/src/sync/report.ts +37 -2
  74. package/src/sync/sync-result.ts +5 -2
  75. package/src/sync/sync.ts +13 -5
  76. package/src/task-signals/claude-signals.ts +6 -5
  77. package/src/watch/command.ts +56 -17
  78. package/src/watch/lane-message.ts +1 -1
  79. package/src/watch/lane-progress.ts +51 -2
  80. package/src/watch/lane-report.ts +57 -2
  81. package/src/watch/lane-watch.ts +2 -8
  82. package/src/watch/task-parallel-touched.ts +9 -2
  83. package/src/watch/texts.ts +4 -0
@@ -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.14.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
@@ -60,6 +60,9 @@ Each file under `references/` is read when its trigger fires: the step or the ev
60
60
  `origin/{{kit.baseBranch}}` would not have it: § 2 step 0. New rows of a board that is on the base
61
61
  branch need nothing; the herd shows them. Check a plan that joins the board later the same way; one
62
62
  only a working lane's worktree has, the watcher reports as `plan missing`.
63
+ - [ ] **Every plan meets the contract.** `jh plan check <plan>` for each plan a starting lane links. Exit 1,
64
+ or a row that links an issue or a `.scratch/` folder instead of a plan file: [`references/adopt-plan.md`](references/adopt-plan.md),
65
+ before § 2 step 0. Exit 2 goes to the user. Done when each check exits 0.
63
66
  - [ ] Tell the user which lanes start now and which wait, before starting any, and whether § 2 step 0
64
67
  is needed.
65
68
 
@@ -84,7 +87,8 @@ stay approval-bound (§ 5, § 6).
84
87
  When the user wants a plan's lanes merged without a question for a while: § 5, **Standing approval**.
85
88
 
86
89
  0. **Plan commit as the base**, when § 0 found the board or a plan missing on
87
- `origin/{{kit.baseBranch}}`. Ask through `AskUserQuestion`: commit the missing files on a
90
+ `origin/{{kit.baseBranch}}`, or adopted a plan (then the diff round of
91
+ [`references/adopt-plan.md`](references/adopt-plan.md) is this question; ask nothing more). Ask through `AskUserQuestion`: commit the missing files on a
88
92
  documentation branch and open a documentation PR, so the lanes start from that commit? The answer
89
93
  is the approval of that commit, its push and the PR (§ 5). `<topic>` is the plan's file name
90
94
  without date and extension, or the board's directory name when several files are missing. On yes,
@@ -99,7 +103,7 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
99
103
  your judgement.
100
104
  2. **Worktree workspace** from `<base>`: `origin/{{kit.baseBranch}}` when, after
101
105
  `git fetch origin {{kit.baseBranch}}`, `git cat-file -e origin/{{kit.baseBranch}}:<plan>` succeeds
102
- for the lane's plan, else `origin/docs/plan-<topic>` of step 0. The file decides, not the state of
106
+ for the lane's plan and `git diff --quiet origin/{{kit.baseBranch}} -- <plan>` exits 0, else `origin/docs/plan-<topic>` of step 0. The file decides, not the state of
103
107
  the PR, so a later lane gets the right base:
104
108
  ```bash
105
109
  herdr worktree create --cwd <main-checkout> --branch <branch> --base <base> \
@@ -133,12 +137,16 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
133
137
  within a poll or two: `bunx jackherd herd --ensure`.
134
138
  8. **Prompt** from the file of § 1:
135
139
  ```bash
136
- herdr agent prompt <lane> "$(cat <prompt-file>)"
140
+ herdr agent prompt <lane> "$(cat <prompt-file>)" --wait --until working --timeout 30000
137
141
  ```
138
142
  The prompt quotes commands in backticks (`git reset`, `/pre-commit`); typed into the command
139
143
  line inside double quotes, your own shell would run them before the lane sees the text. Text
140
144
  read through `$(cat …)` is not expanded again. Every `herdr agent prompt` in this skill goes
141
- this way, from a file you wrote with the `Write` tool or `jh lane prompt` wrote.
145
+ this way, from a file you wrote with the `Write` tool or `jh lane prompt` wrote, with the same
146
+ `--wait` options, so the lane confirms it started. `agent_prompt_stalled` or `timeout` does not prove the
147
+ text is lost: read the screen (`herdr agent read <lane> --source visible --lines 12`). Text still in the input
148
+ line: `herdr agent send-keys <lane> enter`; a lane already at work: done; neither: send the file once more,
149
+ same command. A second stall goes to the user.
142
150
 
143
151
  ## 3. Dev server
144
152
 
@@ -242,7 +250,7 @@ On wake-up:
242
250
  | `blocked` | Read the screen (`--source detection`, or zoom the pane when cut off). A question is vetted (below, **Settled questions stay in the lane**): one the plan or a rule settles you answer in the lane, an open decision goes to the user via `AskUserQuestion`. You never hand the lane's pane to the user. A question dialog in a lane (not a permission dialog) is a gap in the lane prompt (§ 1): send it the text-block rule and the answer by prompt. A permission dialog for a step on the list in § 5 you confirm, after checking the cursor sits on the one-time option; anything else goes to the user. Dim text in the input line is a suggestion, not input (`--format ansi` shows `\e[2m`); in a multi-select dialog `space` toggles an option, `enter` moves on. The watcher prints a duty line for each blocked lane; it is done when the lane has its answer or the user has the question |
243
251
  | `working` with `questions: <n>` | The lane changed the questions of its report while its subagents run. Read `report <path>` and close each new question as the `done` / `idle` row does (`jh settled`, or `AskUserQuestion` with the answer sent on by `herdr agent prompt`). No diff check and no next phase: the lane keeps working |
244
252
  | `main checkout on <branch>, not …` | § 0, **The main checkout stays on `{{kit.baseBranch}}`** |
245
- | `done` / `idle` | Read the report the watcher shows (`report <path>` and its first lines, or the screen); read further only where that is not enough. Check the diff against the `{{kit.heading.writes}}` set (`git -C <worktree> diff --stat <base>...HEAD`, with the ref the lane branched from in § 2 step 2 until it has rebased after the documentation PR's merge) and that checks are quoted with numbers, not claimed. Every blocking question in the report is closed in the same wake-up, also one under open points, one without its mark and also when the `questions: <n>` line is missing: settled by you through `jh settled` (below), or put to the user through `AskUserQuestion` (vetted, below) and the answer sent on to the lane by `herdr agent prompt`. A not blocking one is settled the same way or collected (**Not blocking questions**, below). An answer that stays in your chat with the user is none. Then start the lane's next ready phase (§ 2) or name the reason to wait |
253
+ | `done` / `idle` | Read the report the watcher shows (`report <path>` and its first lines, or the screen); read further only where that is not enough. Check the diff against the `{{kit.heading.writes}}` set (`git -C <worktree> diff --stat <base>...HEAD`, with the ref the lane branched from in § 2 step 2 until it has rebased after the documentation PR's merge) and that checks are quoted with numbers, not claimed. Every blocking question in the report is closed in the same wake-up, also one under open points, one without its mark and also when the `questions: <n>` line is missing: settled by you through `jh settled` (below), or put to the user through `AskUserQuestion` (vetted, below) and the answer sent on to the lane by `herdr agent prompt`. A not blocking one is settled the same way or collected (**Not blocking questions**, below). An answer that stays in your chat with the user is none. Then start the lane's next ready phase (§ 2), at once after a `<lane> ready: …` line, or name the reason to wait; a PR awaiting approval holds only its commit, which stages only its own files |
246
254
  | `merged #<n>` | § 6, for every lane |
247
255
  | `checks failed #<n> (<lane>): <checks> (<k> tests: …)` | Send the finding to the lane with the named checks and tests (data, above). Without names, the lane reads the run itself |
248
256
  | `checks passed #<n>` | One line to the user: ready to merge, with the board's merge order |
@@ -475,6 +483,9 @@ always goes to the user, whatever the plan settles.
475
483
  permission mode refuses the push, push its branch yourself under the same approval: run
476
484
  `cd <worktree>` as a command of its own, then `git push --force-with-lease origin HEAD:<branch>`,
477
485
  then `cd` back (the kit's git guard refuses `git -C`).
486
+ 5. **Drafts** (**{{kit.merge.openAsDraft}}**, `pull-requests.md` → Drafts): when you report a draft PR to the
487
+ user to merge, run `gh pr ready <n>` first; for your own merge, **Self-merge** step 1. The merge guard
488
+ never passes a draft.
478
489
 
479
490
  ### Self-merge
480
491
 
@@ -482,15 +493,17 @@ When the user picked `"{{kit.guard.selfMergeLabel}}"` for a PR:
482
493
 
483
494
  1. Commit, push and open the PR as in step 4. Enter in the board's `{{kit.heading.history}}` that the
484
495
  user approved this PR with self-merge (time, lane, PR number; the time as § 4 reads it), so the
485
- approval has a record outside the chat.
496
+ approval has a record outside the chat. A draft turns ready here: `gh pr ready <n>`.
486
497
  2. **Merging without checks.** The paths a PR may carry to be merged without waiting for checks are
487
498
  `{{kit.merge.withoutChecks}}` (`merge.withoutChecks` in the config; `none` means every merge waits).
488
499
  Unless `none`, list the PR's files with `gh pr diff <n> --name-only` and compare each one with the
489
500
  patterns yourself. When **every** file matches, go straight to step 4. One file outside, an empty
490
501
  list, or a failed `gh` call: go on with step 3. The merge guard reads the file list from GitHub
491
502
  itself; if it asks, the dialog is the user's (step 5).
492
- 3. Wait for the checks in the background, never in the foreground: a Bash call
493
- `gh pr checks <n> --watch` with `run_in_background: true`, then work on until it reports.
503
+ 3. Wait for the checks in the background, never in the foreground. After a ready (step 1), start only
504
+ once every check that counts (each of `guard.requiredChecks`, else each check the draft skipped) has
505
+ a run that started after the ready, as a watch would end on the draft's skipped runs at once. Then a Bash call
506
+ `gh pr checks <n> --watch` with `run_in_background: true`, and work on until it reports.
494
507
  4. Green (or a PR that qualified in step 2): `gh pr merge <n> --squash --delete-branch --match-head-commit <sha>`,
495
508
  with the plain PR number, the full `headRefOid` from `gh pr view <n> --json headRefOid` read right before, and
496
509
  without `-R`, `--repo` or `--admin`. The merge guard lets exactly this command through for the approved branch
@@ -531,7 +544,7 @@ For a PR the user approved (step 2) whose lane works on a plan under a standing
531
544
 
532
545
  1. A PR behind the base branch is rebased first (§ 6, under the PR's approval), so its checks run
533
546
  against the current base.
534
- 2. Wait for the checks as in **Self-merge** step 3.
547
+ 2. Turn a draft ready as in **Self-merge** step 1, then wait for the checks as in its step 3.
535
548
  3. Green: the same command as **Self-merge** step 4, with the `<sha>` read right before. The merge
536
549
  guard lets it through only before the approval's end, with every check green, GitHub able to merge
537
550
  it and no workflow, migration, env file or file of the guards in it; otherwise it asks, and that dialog is the user's