jackherd 1.16.0 → 1.17.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 (155) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +22 -21
  4. package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.js +57 -5
  7. package/dist/plan-format.d.ts +18 -6
  8. package/dist/plan-format.js +8 -1
  9. package/docs/plan-contract.md +1 -0
  10. package/herdr-plugin/herdr-plugin.toml +4 -1
  11. package/herdr-plugin/restore-plans.ts +60 -26
  12. package/herdr-plugin/resume.ts +18 -0
  13. package/install.sh +1 -1
  14. package/kit/hooks/ask-rules.json +11 -0
  15. package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
  16. package/kit/hooks/hooks/approval-gate/index.ts +36 -7
  17. package/kit/hooks/hooks/approval-gate/run.ts +72 -16
  18. package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
  19. package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
  20. package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
  21. package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
  22. package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
  23. package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
  24. package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
  25. package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
  26. package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
  27. package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
  28. package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
  29. package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
  30. package/kit/hooks/hooks/merge-guard/run.ts +110 -28
  31. package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
  32. package/kit/hooks/hooks/plain-question/form.ts +89 -0
  33. package/kit/hooks/hooks/plain-question/index.ts +44 -4
  34. package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
  35. package/kit/hooks/hooks/plain-question/run.ts +14 -7
  36. package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
  37. package/kit/hooks/hooks.json +11 -2
  38. package/kit/review/agents/skill-review.md +3 -2
  39. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  40. package/kit/rules/rules/kit/code-style.md +6 -6
  41. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  42. package/kit/workflow/skills/follow/SKILL.md +2 -3
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +120 -299
  45. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  46. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  47. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  48. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  49. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  50. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  51. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  52. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  53. package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
  54. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  55. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  56. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  57. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  58. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  59. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  60. package/kit/workflow/skills/plan/SKILL.md +3 -2
  61. package/package.json +2 -1
  62. package/recipes/README.md +1 -0
  63. package/recipes/jira-cli.md +1 -1
  64. package/recipes/sources/google-calendar-today.ts +8 -2
  65. package/recipes/sources/jira.ts +10 -2
  66. package/recipes/sources/linear.ts +9 -3
  67. package/recipes/sources/sentry.ts +8 -2
  68. package/recipes/sources.md +4 -2
  69. package/schemas/jackherd.schema.json +67 -0
  70. package/schemas/sources.schema.json +9 -0
  71. package/src/approval/checked.ts +60 -0
  72. package/src/approval/command.ts +16 -0
  73. package/src/check/command.ts +51 -6
  74. package/src/check/lint-pointers.ts +79 -0
  75. package/src/check/lint-shaped.ts +196 -0
  76. package/src/check/plan-structure-check.ts +116 -4
  77. package/src/check/report.ts +9 -3
  78. package/src/check/schema.ts +37 -0
  79. package/src/check/settings.ts +4 -0
  80. package/src/check/skill-budget-defaults.ts +77 -26
  81. package/src/check/skill-budgets.ts +58 -10
  82. package/src/cli.ts +2 -0
  83. package/src/config/personal-sources.ts +46 -10
  84. package/src/config/repository-identity.ts +40 -0
  85. package/src/config/schema.ts +53 -1
  86. package/src/context/command.ts +33 -2
  87. package/src/context/options.ts +33 -8
  88. package/src/context/usage-logs.ts +53 -0
  89. package/src/context/usage.ts +110 -0
  90. package/src/lane/base-command.ts +35 -0
  91. package/src/lane/base.ts +43 -0
  92. package/src/lane/close-check.ts +49 -15
  93. package/src/lane/close-command.ts +71 -11
  94. package/src/lane/close-options.ts +29 -8
  95. package/src/lane/close-survey.ts +13 -1
  96. package/src/lane/close.ts +51 -12
  97. package/src/lane/command.ts +21 -3
  98. package/src/lane/send-command.ts +32 -0
  99. package/src/lane/send-herdr.ts +77 -0
  100. package/src/lane/send-options.ts +23 -0
  101. package/src/lane/send.ts +90 -0
  102. package/src/lane/skill-hint.ts +27 -0
  103. package/src/lane/space-command.ts +30 -0
  104. package/src/lane/space.ts +49 -0
  105. package/src/lane/start-lane-hint.ts +18 -0
  106. package/src/plan-contract/base-check-git.ts +33 -0
  107. package/src/plan-contract/base-check.ts +17 -0
  108. package/src/plan-contract/command.ts +25 -4
  109. package/src/plan-format/plan-structure.ts +17 -5
  110. package/src/plans/ask-coordinator.ts +23 -13
  111. package/src/plans/board-rows.ts +9 -4
  112. package/src/plans/click-hint.ts +4 -0
  113. package/src/plans/command.ts +6 -5
  114. package/src/plans/ensure-panes.ts +14 -3
  115. package/src/plans/git.ts +14 -0
  116. package/src/plans/github.ts +19 -10
  117. package/src/plans/layout.ts +82 -33
  118. package/src/plans/live.ts +8 -12
  119. package/src/plans/plan-panes.ts +21 -4
  120. package/src/plans/selection.ts +29 -0
  121. package/src/plans/settings.ts +4 -0
  122. package/src/resume/command.ts +213 -0
  123. package/src/resume/decision.ts +102 -0
  124. package/src/resume/keeper.ts +65 -0
  125. package/src/resume/location.ts +22 -0
  126. package/src/resume/log.ts +27 -0
  127. package/src/resume/ports.ts +102 -0
  128. package/src/resume/record.ts +116 -0
  129. package/src/resume/targets.ts +84 -0
  130. package/src/resume/texts.ts +27 -0
  131. package/src/secrets/command.ts +15 -5
  132. package/src/sync/herdr-plugin.ts +2 -11
  133. package/src/sync/plan-ask-rules.ts +11 -0
  134. package/src/sync/render.ts +3 -1
  135. package/src/vocabulary/index.ts +13 -0
  136. package/src/waiting/command.ts +13 -4
  137. package/src/watch/command.ts +177 -78
  138. package/src/watch/config-reload.ts +119 -0
  139. package/src/watch/lane-model.ts +11 -1
  140. package/src/watch/lane-progress.ts +70 -8
  141. package/src/watch/lane-report.ts +66 -11
  142. package/src/watch/main-checkout.ts +5 -1
  143. package/src/watch/merge-duties.ts +33 -0
  144. package/src/watch/merge-overlap-read.ts +69 -0
  145. package/src/watch/plan-views.ts +16 -4
  146. package/src/watch/reference-paths.ts +46 -5
  147. package/src/watch/report-questions.ts +5 -2
  148. package/src/watch/single-report.ts +3 -2
  149. package/src/watch/source-dry-run-command.ts +22 -4
  150. package/src/watch/source-reason.ts +58 -0
  151. package/src/watch/source-reload.ts +21 -17
  152. package/src/watch/source-run.ts +24 -7
  153. package/src/watch/sources.ts +9 -2
  154. package/src/watch/texts.ts +14 -3
  155. package/src/watch/waiting-line.ts +4 -3
@@ -0,0 +1,119 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isDeepStrictEqual } from 'node:util'
4
+ import { locateConfigFile, PROJECT_CONFIG_PATH } from '../config/location'
5
+ import { type ResolvedConfig, readResolvedConfig } from '../config/read-config'
6
+ import { readRepositoryIdentity } from '../config/repository-identity'
7
+ import type { JackherdConfig } from '../config/schema'
8
+ import {
9
+ type FileStamps,
10
+ type LoadedSources,
11
+ nextLoadedSources,
12
+ type ReadFailure,
13
+ readWatchedSources,
14
+ reasonOf,
15
+ } from './source-reload'
16
+ import type { SourceSettings } from './sources'
17
+ import type { WorkflowRun } from './workflow-runs'
18
+
19
+ /** What a line starts with when a changed config left the values as they were. */
20
+ export const CONFIG_NOT_RELOADED = 'config not reloaded:'
21
+
22
+ /** The parts of `watch` the loop takes up while it runs, in the order a reload names them. */
23
+ const LIVE_PARTS = ['deploy', 'feedback', 'previews', 'checks'] as const
24
+
25
+ export type LivePart = (typeof LIVE_PARTS)[number]
26
+
27
+ /** The values of `watch` that change while the watcher runs: what it reports and how it reads it. */
28
+ export type LiveValues = Pick<JackherdConfig['watch'], LivePart>
29
+
30
+ /** The sources and the live values the watcher runs; the change times of their files travel with `sources`. */
31
+ export type LoadedConfig = { sources: LoadedSources; live: LiveValues }
32
+
33
+ /** One read of the repository config and the personal file: the live values and merged sources with the notes, or the line naming the file that failed. */
34
+ export type ConfigRead = { ok: true; live: LiveValues; sources: SourceSettings[]; notes: string[] } | ReadFailure
35
+
36
+ /** What a reload starts counting from: runs and heads that existed before it are the baseline, not news. */
37
+ export type Baselines = { deploy: boolean; previews: boolean }
38
+
39
+ export const liveValuesOf = (config: JackherdConfig): LiveValues => ({
40
+ deploy: config.watch.deploy,
41
+ feedback: config.watch.feedback,
42
+ previews: config.watch.previews,
43
+ checks: config.watch.checks,
44
+ })
45
+
46
+ /** The parts of `next` that differ from `previous` by value. */
47
+ export const changedParts = (previous: LiveValues, next: LiveValues): LivePart[] =>
48
+ LIVE_PARTS.filter((part) => !isDeepStrictEqual(previous[part], next[part]))
49
+
50
+ /**
51
+ * Whether the reload starts a baseline: a deploy workflow that is new or another one (a removed `deploy` reports
52
+ * nothing anyway), and previews that were off before. A change of the environments or of the preview authors keeps
53
+ * what the watcher has recorded.
54
+ */
55
+ export const baselinesOf = (previous: LiveValues, next: LiveValues): Baselines => ({
56
+ deploy: next.deploy !== undefined && next.deploy.workflow !== previous.deploy?.workflow,
57
+ previews: next.previews !== undefined && previous.previews === undefined,
58
+ })
59
+
60
+ /** The runs created at or after `since`, all of them while there is no baseline (null). */
61
+ export const withoutRunsBefore = (runs: WorkflowRun[], since: number | null): WorkflowRun[] =>
62
+ since === null ? runs : runs.filter((run) => Date.parse(run.createdAt) >= since)
63
+
64
+ const configPathOf = (roots: string[], home: string): string =>
65
+ locateConfigFile(roots, home, existsSync) ?? join(roots[0] ?? home, PROJECT_CONFIG_PATH)
66
+
67
+ const readRepositoryConfig = (
68
+ roots: string[],
69
+ home: string,
70
+ path: string,
71
+ ): { ok: true; resolved: ResolvedConfig } | ReadFailure => {
72
+ try {
73
+ return { ok: true, resolved: readResolvedConfig(roots, home) }
74
+ } catch (error) {
75
+ return { ok: false, line: `${CONFIG_NOT_RELOADED} ${path}: ${reasonOf(path, error)}` }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Reads the repository config once (`readResolvedConfig` finds it in `roots`) and the personal sources of `home`:
81
+ * the live values and the merged sources, for the repository `origin` of the last of `roots` (the main checkout)
82
+ * names (`readRepositoryIdentity`). An invalid or missing config is the line `config not reloaded:` with its
83
+ * file and reason; an invalid personal file keeps the whole reading back (`readWatchedSources`).
84
+ */
85
+ export const readWatchedConfig = (roots: string[], home: string): ConfigRead => {
86
+ const path = configPathOf(roots, home)
87
+ const repository = readRepositoryConfig(roots, home, path)
88
+ if (!repository.ok) return repository
89
+ const { config, notes } = repository.resolved
90
+ const mainRoot = roots.at(-1)
91
+ const identity = mainRoot === undefined ? null : readRepositoryIdentity(mainRoot)
92
+ const sources = readWatchedSources({ path, read: () => config.watch.sources }, home, identity)
93
+ return sources.ok
94
+ ? { ok: true, live: liveValuesOf(config), sources: sources.sources, notes: [...notes, ...sources.notes] }
95
+ : sources
96
+ }
97
+
98
+ /**
99
+ * The config after one cycle: with the stamps as they were, as it was and without a read; after a change, what `read`
100
+ * gives, with `config reloaded: <parts>` when live values differ; when a changed file is invalid, everything as it was
101
+ * and the line naming the file. The new stamps are taken either way, so one change reports once.
102
+ */
103
+ export const nextLoadedConfig = (
104
+ previous: LoadedConfig,
105
+ stamps: FileStamps,
106
+ read: () => ConfigRead,
107
+ ): { loaded: LoadedConfig; lines: string[] } => {
108
+ const taken: { live?: LiveValues } = {}
109
+ const sources = nextLoadedSources(previous.sources, stamps, () => {
110
+ const result = read()
111
+ if (!result.ok) return result
112
+ taken.live = result.live
113
+ return { ok: true, sources: result.sources, notes: result.notes }
114
+ })
115
+ const live = taken.live ?? previous.live
116
+ const changed = changedParts(previous.live, live)
117
+ const lines = changed.length > 0 ? [...sources.lines, `config reloaded: ${changed.join(', ')}`] : sources.lines
118
+ return { loaded: { sources: sources.loaded, live }, lines }
119
+ }
@@ -1,4 +1,4 @@
1
- import { differsFromColumn } from '../plan-format'
1
+ import { differsFromColumn, type LaneProgress, type SprintLane } from '../plan-format'
2
2
  import { isRecord } from '../shared/is-record'
3
3
 
4
4
  export const LANE_MODEL_EVENT = 'model'
@@ -14,6 +14,16 @@ export type LaneModelReading = { model: string | null; idle: boolean; column: st
14
14
 
15
15
  export type LaneModelEvent = { kind: typeof LANE_MODEL_EVENT; lane: string; model: string; column: string }
16
16
 
17
+ /**
18
+ * The model column a lane's agent is measured against: the row of the phase it works on (`progress.current`), else
19
+ * the lane's newest row, which also stands in when the current step has no board row of its own. Undefined for a
20
+ * lane without rows.
21
+ */
22
+ export const modelColumnOf = (lane: SprintLane, progress: LaneProgress | undefined): string | undefined => {
23
+ const current = progress?.current ? progress.steps[progress.current.stepIndex]?.step : undefined
24
+ return (current && !current.withoutRow ? current : lane.steps.at(-1))?.model
25
+ }
26
+
17
27
  const differs = (reading: LaneModelReading): reading is LaneModelReading & { model: string } =>
18
28
  reading.model !== null && differsFromColumn(reading.column, reading.model)
19
29
 
@@ -1,14 +1,26 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
- import { currentPhase, type LaneProgress, parallelGroupsOfPlan, resolvePlanPath, type SprintLane } from '../plan-format'
3
+ import { laneBuild } from '../check/plan-structure-check'
4
+ import { isObjectId } from '../lane/close-survey'
5
+ import {
6
+ currentPhase,
7
+ type LaneProgress,
8
+ mergedPhasesWithOpenBoxes,
9
+ type PlanStep,
10
+ parallelGroupsOfPlan,
11
+ phaseIdsOfCell,
12
+ resolvePlanPath,
13
+ type SprintLane,
14
+ } from '../plan-format'
4
15
  import { boardRowsOf } from '../plans/board-rows'
5
16
  import { type BaseReader, baseReaderOf, gitStatusOf } from '../plans/git'
6
17
  import { laneWorkOf, readPlans, textAtBase } from '../plans/lane-plans'
7
18
  import { laneHeads, type PlanPullRequest, planPullRequests } from '../plans/plan-heads'
8
19
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
9
20
  import { workedLaneProgress } from '../plans/worked-phases'
21
+ import type { PlanLanguage } from '../vocabulary'
10
22
  import { parallelBatchOf } from './lane-parallel'
11
- import { type ReadyPhase, readyPhasesOf } from './lane-report'
23
+ import { type OpenBoxesOf, type ReadyPhase, readyPhasesOf } from './lane-report'
12
24
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
13
25
 
14
26
  /** The open pull requests of the watcher's list that change a plan, as the herd chooses them. */
@@ -107,6 +119,29 @@ export const watchedLaneParallel = (
107
119
  return groups === null ? [] : parallelBatchOf(groups)
108
120
  }
109
121
 
122
+ /**
123
+ * Reads the open boxes of the phase a merged lane built from the lane's worktree, which still holds the pull request's
124
+ * plan and board row, against the merge commit's parent (what the pull request ticked): the check's `laneBuild`. Null for a lane without a worktree or a phase without open boxes, and
125
+ * when anything on the way throws, so the merge line stays the plain duty.
126
+ */
127
+ export const worktreeOpenBoxes =
128
+ (root: string, settings: PlansSettings): OpenBoxesOf =>
129
+ (lane, mergeCommit) => {
130
+ try {
131
+ const build = laneBuild({
132
+ root: worktreeOf(root, settings, lane),
133
+ lane,
134
+ planDirectory: settings.planDirectory,
135
+ board: settings.board,
136
+ language: settings.planLanguage,
137
+ baseOf: mergeCommit !== null && isObjectId(mergeCommit) ? baseReaderOf(root, `${mergeCommit}^`) : () => null,
138
+ })
139
+ return build?.open ? { phaseId: build.open.id, lines: build.open.openLines } : null
140
+ } catch {
141
+ return null
142
+ }
143
+ }
144
+
110
145
  /** A phase the lanes work on, keyed by its plan link as the board spells it, since two plans may share an id. */
111
146
  const workedPhaseKey = (planHref: string, id: string): string => `${planHref}#${id}`
112
147
 
@@ -126,6 +161,30 @@ export const workedPhaseKeys = (progress: Readonly<Record<string, LaneProgress>>
126
161
  }),
127
162
  )
128
163
 
164
+ /** A clause of a board row that reports a merged pull request: `gemergt (#<number>)`, `merged (#<number>)`. */
165
+ const MERGED_CLAUSE = /(?:gemergt|merged)\s*\(#\d+/i
166
+
167
+ /**
168
+ * The phases of `planHref` that are merged: those a clause (`;`-separated) of a board row's phases cell marks `gemergt
169
+ * (#…)`, and those the base branch carries while the plan still has open boxes (`mergedPhasesWithOpenBoxes`, what a
170
+ * merged pull request leaves behind, so no extra GitHub call). Without a base plan, the board rows alone count.
171
+ */
172
+ const mergedPhaseIdsOf = (
173
+ steps: readonly PlanStep[],
174
+ planHref: string,
175
+ text: string,
176
+ base: string | null,
177
+ language: PlanLanguage,
178
+ ): Set<string> => {
179
+ const fromRows = steps
180
+ .filter((step) => step.planHref === planHref)
181
+ .flatMap((step) => step.label.split(';'))
182
+ .filter((clause) => MERGED_CLAUSE.test(clause))
183
+ .flatMap((clause) => phaseIdsOfCell(clause, language))
184
+ const fromBase = base === null ? [] : mergedPhasesWithOpenBoxes(text, base, language).map(({ id }) => id)
185
+ return new Set([...fromRows, ...fromBase])
186
+ }
187
+
129
188
  /**
130
189
  * Per lane, the open phases of its plan whose dependencies are all ticked off and that no lane works on (`worked`, from
131
190
  * `workedPhaseKeys`), by the plan's dependency table and ticks, as the lane's worktree has them; empty for a lane
@@ -142,12 +201,15 @@ export const watchedReadyPhases = (
142
201
  lanes.map((lane) => {
143
202
  const planHref = newestPlanHref(lane)
144
203
  const text = watchedLanePlanText(root, settings, boardFile, lane)
145
- return [
146
- lane.name,
147
- text === null || planHref === null
148
- ? []
149
- : readyPhasesOf(text, settings.planLanguage, phaseIdsWorkedIn(worked, planHref)),
150
- ]
204
+ if (text === null || planHref === null) return [lane.name, []]
205
+ const merged = mergedPhaseIdsOf(
206
+ lane.steps,
207
+ planHref,
208
+ text,
209
+ baseLanePlanText(root, settings, boardFile, lane),
210
+ settings.planLanguage,
211
+ )
212
+ return [lane.name, readyPhasesOf(text, settings.planLanguage, phaseIdsWorkedIn(worked, planHref), merged)]
151
213
  }),
152
214
  )
153
215
 
@@ -26,6 +26,7 @@ import type { LaneModelEvent } from './lane-model'
26
26
  import type { LaneParallelEvent } from './lane-parallel'
27
27
  import { laneStalledLine } from './lane-stalled'
28
28
  import { type LaneSnapshot, NO_AGENT } from './lane-watch'
29
+ import { MERGE_OVERLAP, type MergeOverlap } from './merge-duties'
29
30
  import { PLAN_MISSING_EVENT, type PlanMissingEvent, planMissingLine } from './plan-missing'
30
31
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
31
32
  import { SOURCE_FIELD } from './source-fields'
@@ -308,18 +309,62 @@ type MergeEvent = PullRequestEvent & { kind: typeof WATCH_EVENT.merged }
308
309
 
309
310
  const isMerge = (event: WatchEvent): event is MergeEvent => event.kind === WATCH_EVENT.merged
310
311
 
311
- const mergeDuty = (merged: MergeEvent, lane: string, texts: WatchTexts): string => {
312
- const duty = lane === merged.lane ? texts.mergedLaneDuty : texts.otherLaneDuty
313
- return `${texts.afterMerge(merged.number)}: ${terminalSafe(lane)} ${duty}`
312
+ /** Which checks a merge asks of a lane that rebases; null where the overlap could not be told: in doubt, all of them run. */
313
+ export type OverlapOf = (lane: string, mergeCommit: string | null) => MergeOverlap | null
314
+
315
+ const checksText = (overlap: MergeOverlap, texts: WatchTexts): string => {
316
+ if (overlap.kind === MERGE_OVERLAP.all) return texts.mergeChecksAll
317
+ if (overlap.kind === MERGE_OVERLAP.none) return texts.mergeChecksNone
318
+ return texts.mergeChecksRoots(overlap.roots.map(terminalSafe).join(', '))
319
+ }
320
+
321
+ const mergeDuty = (merged: MergeEvent, lane: string, texts: WatchTexts, overlapOf: OverlapOf | undefined): string => {
322
+ const prefix = `${texts.afterMerge(merged.number)}: ${terminalSafe(lane)}`
323
+ if (lane === merged.lane) return `${prefix} ${texts.mergedLaneDuty}`
324
+ if (overlapOf === undefined) return `${prefix} ${texts.otherLaneDuty}`
325
+ const overlap = overlapOf(lane, merged.mergeCommit) ?? { kind: MERGE_OVERLAP.all }
326
+ return `${prefix} ${texts.otherLaneDuty} · ${checksText(overlap, texts)}`
314
327
  }
315
328
 
316
329
  /**
317
330
  * What a merge asks of every watched lane (`orchestrate` § 6), one line per lane and merge: the
318
- * merged lane goes on or closes, every other lane rebases and goes on or waits with a reason. The
319
- * watcher only names the duty; the coordinator decides and prompts the lanes.
331
+ * merged lane goes on or closes, every other lane rebases and goes on or waits with a reason, and
332
+ * `overlapOf` adds which checks that rebase needs where the files the merge changed reach the lane.
333
+ * The watcher only names the duty; the coordinator decides and prompts the lanes.
334
+ */
335
+ export const mergeDutyLines = (
336
+ events: readonly WatchEvent[],
337
+ lanes: readonly string[],
338
+ texts: WatchTexts,
339
+ overlapOf?: OverlapOf,
340
+ ): string[] =>
341
+ events.filter(isMerge).flatMap((merged) => lanes.map((lane) => mergeDuty(merged, lane, texts, overlapOf)))
342
+
343
+ /** The phase a merged lane built with the lines of its open boxes in the plan. */
344
+ export type OpenBoxesFound = { phaseId: string; lines: readonly number[] }
345
+
346
+ /**
347
+ * Reads the open boxes of the phase a merged lane built, the plan of the pull request against its parent
348
+ * (`mergeCommit`, null where unknown); null where there is none or the lane cannot be read.
349
+ */
350
+ export type OpenBoxesOf = (lane: string, mergeCommit: string | null) => OpenBoxesFound | null
351
+
352
+ /**
353
+ * What a merged phase with open boxes asks of the coordinator (`orchestrate` § 6): one line per merge whose lane
354
+ * (`openBoxesOf`) leaves boxes open, `<lane> P<n> merged with <k> open boxes at <lines>`. A merged pull request without
355
+ * a lane has no phase to read.
320
356
  */
321
- export const mergeDutyLines = (events: readonly WatchEvent[], lanes: readonly string[], texts: WatchTexts): string[] =>
322
- events.filter(isMerge).flatMap((merged) => lanes.map((lane) => mergeDuty(merged, lane, texts)))
357
+ export const openBoxesDutyLines = (
358
+ events: readonly WatchEvent[],
359
+ openBoxesOf: OpenBoxesOf,
360
+ texts: WatchTexts,
361
+ ): string[] =>
362
+ events.filter(isMerge).flatMap(({ lane, mergeCommit }) => {
363
+ const found = lane === null ? null : openBoxesOf(lane, mergeCommit)
364
+ return lane === null || found === null
365
+ ? []
366
+ : [`${terminalSafe(lane)} ${texts.mergedWithOpenBoxes(phaseLabel(found.phaseId), found.lines)}`]
367
+ })
323
368
 
324
369
  const isLaneAlert = (event: WatchEvent): event is LaneFillEvent =>
325
370
  event.kind === WATCH_EVENT.laneFill && event.level === CONTEXT_LEVEL.alert
@@ -388,10 +433,16 @@ export type ReadyPhase = { id: string; after: string[] }
388
433
 
389
434
  /**
390
435
  * The phases of a plan that can start now, in table order: open, not worked on (`worked`, the ids of the phases the
391
- * lanes work on), every phase of the `Depends on` cell finished and no external gate in it. Empty for a plan without
392
- * a dependency table or a row without a phase in the plan.
436
+ * lanes work on), not merged (`merged`: a merged phase may keep an open box, e.g. its acceptance), every phase of the
437
+ * `Depends on` cell finished and no external gate in it. Empty for a plan without a dependency table or a row without
438
+ * a phase in the plan.
393
439
  */
394
- export const readyPhasesOf = (markdown: string, language: PlanLanguage, worked: ReadonlySet<string>): ReadyPhase[] => {
440
+ export const readyPhasesOf = (
441
+ markdown: string,
442
+ language: PlanLanguage,
443
+ worked: ReadonlySet<string>,
444
+ merged: ReadonlySet<string> = new Set(),
445
+ ): ReadyPhase[] => {
395
446
  const table = parseDependencyTable(markdown, language) ?? []
396
447
  const phases = parsePlanPhases(markdown, language)
397
448
  const finished = new Set(phases.filter(isPhaseFinished).map(({ id }) => id))
@@ -399,7 +450,11 @@ export const readyPhasesOf = (markdown: string, language: PlanLanguage, worked:
399
450
  return table
400
451
  .filter(
401
452
  ({ id, after, external }) =>
402
- open.has(id) && !worked.has(id) && external.length === 0 && after.every((each) => finished.has(each)),
453
+ open.has(id) &&
454
+ !worked.has(id) &&
455
+ !merged.has(id) &&
456
+ external.length === 0 &&
457
+ after.every((each) => finished.has(each)),
403
458
  )
404
459
  .map(({ id, after }) => ({ id, after }))
405
460
  }
@@ -15,6 +15,10 @@ export const behindCountOf = (revList: string | null): number | null => {
15
15
  return COUNT.test(count) ? Number(count) : null
16
16
  }
17
17
 
18
+ /** True when the main checkout has the base checked out and lacks commits of the remote base. */
19
+ export const isBehindOnBase = (read: MainCheckoutRead | null, baseBranch: string): boolean =>
20
+ read !== null && read.branch === baseBranch && (read.behind ?? 0) > 0
21
+
18
22
  /**
19
23
  * The duty line of the main checkout, in every report and heartbeat until it is resolved. With another branch or a
20
24
  * detached head checked out: `main checkout on <branch>, not <base>`, since the main checkout is the coordinator's seat
@@ -29,6 +33,6 @@ export const mainCheckoutDutyLine = (
29
33
  ): string | null => {
30
34
  if (read === null) return null
31
35
  if (read.branch !== baseBranch) return terminalSafe(texts.mainCheckoutOffBase(read.branch, baseBranch))
32
- if (read.behind === null || read.behind === 0) return null
36
+ if (!isBehindOnBase(read, baseBranch) || read.behind === null) return null
33
37
  return terminalSafe(texts.mainCheckoutBehind(read.remoteBase, read.behind))
34
38
  }
@@ -0,0 +1,33 @@
1
+ /** What a lane writes: the paths of its phases' writes cells (`''` is the whole repository) and the plan it links. */
2
+ export type LaneScope = { writes: readonly string[]; plan: string | null }
3
+
4
+ export const MERGE_OVERLAP = { all: 'all', roots: 'roots', none: 'none' } as const
5
+
6
+ /** What a merge asks of a lane's checks: all of them, those of the named roots (or plans), or none. */
7
+ export type MergeOverlap =
8
+ | { kind: typeof MERGE_OVERLAP.all }
9
+ | { kind: typeof MERGE_OVERLAP.roots; roots: string[] }
10
+ | { kind: typeof MERGE_OVERLAP.none }
11
+
12
+ const withoutTrailingSlash = (path: string): string => path.replace(/\/+$/, '')
13
+
14
+ const isUnder = (file: string, path: string): boolean => file === path || file.startsWith(`${path}/`)
15
+
16
+ /**
17
+ * Whether the files a merge changed reach a lane (`orchestrate` § 6): a change to the lock file or the root manifest
18
+ * (`sharedFiles`), a lane that writes the whole repository, or one whose writes are unknown run every check; a change
19
+ * under a path the lane writes, or to its plan, runs the checks of that path; anything else needs a rebase and no run.
20
+ */
21
+ export const overlapOf = (
22
+ changed: readonly string[],
23
+ { writes, plan }: LaneScope,
24
+ sharedFiles: readonly string[],
25
+ ): MergeOverlap => {
26
+ if (changed.length === 0) return { kind: MERGE_OVERLAP.none }
27
+ if (changed.some((file) => sharedFiles.includes(file))) return { kind: MERGE_OVERLAP.all }
28
+ if (writes.length === 0 || writes.includes('')) return { kind: MERGE_OVERLAP.all }
29
+ const hits = [...writes.map(withoutTrailingSlash), ...(plan === null ? [] : [plan])].filter((path) =>
30
+ changed.some((file) => isUnder(file, path)),
31
+ )
32
+ return hits.length === 0 ? { kind: MERGE_OVERLAP.none } : { kind: MERGE_OVERLAP.roots, roots: [...new Set(hits)] }
33
+ }
@@ -0,0 +1,69 @@
1
+ import { STACKS } from '../config/detect'
2
+ import { isObjectId } from '../lane/close-survey'
3
+ import { parseSprintLanes } from '../plan-format'
4
+ import type { PlansSettings } from '../plans/settings'
5
+ import { lanePlanPath, watchedLanePlanText } from './lane-progress'
6
+ import type { OverlapOf } from './lane-report'
7
+ import { type LaneScope, type MergeOverlap, overlapOf } from './merge-duties'
8
+ import { planTaskViewOf } from './task-parallel-input'
9
+
10
+ /** The files at the repository root that every package depends on: the lock files and manifests of the known stacks. */
11
+ const SHARED_FILES: readonly string[] = [
12
+ ...new Set(Object.values(STACKS).flatMap((stack) => [...stack.lockFiles, stack.rootMarker])),
13
+ ]
14
+
15
+ /** The files the merge commit changed, null when git cannot say (no such commit, a root commit). */
16
+ const changedFilesOf = (root: string, mergeCommit: string): string[] | null => {
17
+ if (!isObjectId(mergeCommit)) return null
18
+ const result = Bun.spawnSync(
19
+ ['git', '-C', root, 'diff', '--name-only', '--end-of-options', `${mergeCommit}^`, mergeCommit],
20
+ { stdout: 'pipe', stderr: 'ignore' },
21
+ )
22
+ return result.exitCode === 0 ? result.stdout.toString().split('\n').filter(Boolean) : null
23
+ }
24
+
25
+ /** The overlap of one lane; null when anything on the way throws (git, the plan file), so the line stays safe. */
26
+ const overlapFor = (read: () => MergeOverlap | null): MergeOverlap | null => {
27
+ try {
28
+ return read()
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ /** What a lane writes, from the writes cells of its plan and the plan itself; null without a readable plan. */
35
+ const scopeOf = (
36
+ root: string,
37
+ settings: PlansSettings,
38
+ boardFile: string,
39
+ boardText: string,
40
+ name: string,
41
+ ): LaneScope | null => {
42
+ const lane = parseSprintLanes(boardText, settings.planLanguage).find((candidate) => candidate.name === name)
43
+ if (lane === undefined) return null
44
+ const text = watchedLanePlanText(root, settings, boardFile, lane)
45
+ if (text === null) return null
46
+ const writes = Object.values(planTaskViewOf(text, settings.planLanguage).phaseWrites).flat()
47
+ return { writes, plan: lanePlanPath(root, settings, boardFile, lane) }
48
+ }
49
+
50
+ /**
51
+ * Which checks each merge asks of each rebasing lane: the files the merge commit changed against what the lane
52
+ * writes (`overlapOf`). Null for a lane or a commit it cannot tell for, so the line stays the plain duty.
53
+ */
54
+ export const mergeOverlapReader = (
55
+ root: string,
56
+ settings: PlansSettings,
57
+ boardFile: string,
58
+ boardText: string,
59
+ ): OverlapOf => {
60
+ const changed = new Map<string, string[] | null>()
61
+ return (lane, mergeCommit) =>
62
+ overlapFor(() => {
63
+ if (mergeCommit === null) return null
64
+ if (!changed.has(mergeCommit)) changed.set(mergeCommit, changedFilesOf(root, mergeCommit))
65
+ const files = changed.get(mergeCommit) ?? null
66
+ const scope = scopeOf(root, settings, boardFile, boardText, lane)
67
+ return files === null || scope === null ? null : overlapOf(files, scope, SHARED_FILES)
68
+ })
69
+ }
@@ -18,7 +18,12 @@ export type PlanViews = { failureReported: boolean }
18
18
 
19
19
  type Ensured = { ok: true; lines: string[] } | { ok: false; error: string }
20
20
 
21
- type Effects = { ensure: () => string[]; log: (line: string) => void }
21
+ type Effects = {
22
+ ensure: () => string[]
23
+ log: (line: string) => void
24
+ /** ` · read <path>` of the reference that holds the handling of a failure; none when omitted. */
25
+ failureHint?: () => string
26
+ }
22
27
 
23
28
  export const insideHerdr = (adapter: PlansSettings['adapter'], env: Record<string, string | undefined>): boolean =>
24
29
  adapter === TERMINAL_ADAPTER.herdr && env.HERDR_ENV === '1'
@@ -32,11 +37,18 @@ export const ensureDue = (trigger: EnsureTrigger): boolean =>
32
37
  trigger.kind === ENSURE_TRIGGER.start || trigger.added.length > 0 || trigger.withNewAgent.length > 0
33
38
 
34
39
  /** The lines one run leaves in the watcher's output: what it brought back, or a failure once per streak. */
35
- export const ensuredLines = (ensured: Ensured, views: PlanViews): { lines: string[]; views: PlanViews } => {
40
+ export const ensuredLines = (
41
+ ensured: Ensured,
42
+ views: PlanViews,
43
+ failureHint = '',
44
+ ): { lines: string[]; views: PlanViews } => {
36
45
  if (ensured.ok)
37
46
  return { lines: ensured.lines.map((line) => `herd --ensure: ${line}`), views: { failureReported: false } }
38
47
  if (views.failureReported) return { lines: [], views }
39
- return { lines: [`herd --ensure failed, still watching: ${ensured.error}`], views: { failureReported: true } }
48
+ return {
49
+ lines: [`herd --ensure failed, still watching: ${ensured.error}${failureHint}`],
50
+ views: { failureReported: true },
51
+ }
40
52
  }
41
53
 
42
54
  const ensuredOf = (ensure: () => string[]): Ensured => {
@@ -53,7 +65,7 @@ const ensuredOf = (ensure: () => string[]): Ensured => {
53
65
  */
54
66
  export const keepPlanViews = (trigger: EnsureTrigger, views: PlanViews, effects: Effects): PlanViews => {
55
67
  if (!ensureDue(trigger)) return views
56
- const { lines, views: next } = ensuredLines(ensuredOf(effects.ensure), views)
68
+ const { lines, views: next } = ensuredLines(ensuredOf(effects.ensure), views, effects.failureHint?.())
57
69
  for (const line of lines) effects.log(line)
58
70
  return next
59
71
  }
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path'
2
2
  import { claudePath, SKILLS_DIRECTORY } from '../claude/paths'
3
3
  import { CONTEXT_LEVEL } from '../context'
4
+ import { type LaneSetChange, laneSetLines } from '../plans/lane-discovery'
4
5
  import { APPOINTMENT_EVENT } from './appointments'
5
6
  import { UPDATE_AVAILABLE_EVENT, WATCH_EVENT } from './watch-events'
6
7
 
@@ -14,6 +15,14 @@ export const REFERENCE = {
14
15
  coordinatorHandover: 'references/coordinator-handover.md',
15
16
  closeLane: 'references/close-lane.md',
16
17
  laneHandover: 'references/lane-handover.md',
18
+ startLane: 'references/start-lane.md',
19
+ appointments: 'references/appointments.md',
20
+ stageApproval: 'references/stage-approval.md',
21
+ planCommit: 'references/plan-commit.md',
22
+ herdAsk: 'references/herd-ask.md',
23
+ watchWithoutMonitor: 'references/watch-without-monitor.md',
24
+ adoptPlan: 'references/adopt-plan.md',
25
+ waiting: 'references/waiting.md',
17
26
  } as const
18
27
 
19
28
  export type Reference = (typeof REFERENCE)[keyof typeof REFERENCE]
@@ -24,9 +33,9 @@ export type SkillCopy = { shown: string; path: string }
24
33
  const SKILL_ENTRY = 'SKILL.md'
25
34
 
26
35
  /**
27
- * The rare events and the reference that holds their handling. The frequent ones (`blocked`, `done`/`idle`, `merged`,
28
- * the checks, `heartbeat`, `approval`, the lanes' fill, the watcher's end, `kit updated`) stay in the skill and have
29
- * no path, so their lines do not grow.
36
+ * The events that need a reference and the file that holds their handling. The frequent ones (`blocked`, `done`/`idle`,
37
+ * `merged`, the checks, `heartbeat`, an `approval` of a tool that is no standing stage tool) have no path, so their
38
+ * lines do not grow. A lane's fill and an approval depend on more than the kind: see `eventReference`.
30
39
  */
31
40
  const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
32
41
  [WATCH_EVENT.feedback]: REFERENCE.events,
@@ -38,6 +47,9 @@ const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
38
47
  [WATCH_EVENT.noTaskSignal]: REFERENCE.events,
39
48
  [WATCH_EVENT.unlinkedArtifact]: REFERENCE.events,
40
49
  [WATCH_EVENT.planMissing]: REFERENCE.events,
50
+ [WATCH_EVENT.deploy]: REFERENCE.events,
51
+ [WATCH_EVENT.laneStalled]: REFERENCE.events,
52
+ [WATCH_EVENT.due]: REFERENCE.appointments,
41
53
  [UPDATE_AVAILABLE_EVENT]: REFERENCE.events,
42
54
  [WATCH_EVENT.laneModel]: REFERENCE.laneSession,
43
55
  }
@@ -45,13 +57,30 @@ const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
45
57
  const isCoordinatorHandoverLevel = (level: unknown): boolean =>
46
58
  level === CONTEXT_LEVEL.warn || level === CONTEXT_LEVEL.alert
47
59
 
48
- /** The reference to read for `event`; null for a frequent event, and for a coordinator still at `ok`. */
49
- export const eventReference = (event: { kind: string; level?: unknown }): Reference | null => {
60
+ /** The reference of a lane whose context rose: its hand-over at `alert`, the events at `warn`. */
61
+ const laneFillReference = (level: unknown): Reference =>
62
+ level === CONTEXT_LEVEL.alert ? REFERENCE.laneHandover : REFERENCE.events
63
+
64
+ /**
65
+ * The reference to read for `event`; null for a frequent event, for a coordinator still at `ok` and for an approval
66
+ * whose tool is no standing stage tool (`isStageTool`, which only the config can tell).
67
+ */
68
+ export const eventReference = (
69
+ event: { kind: string; level?: unknown; tool?: string },
70
+ isStageTool: (tool: string) => boolean = () => false,
71
+ ): Reference | null => {
50
72
  if (event.kind === WATCH_EVENT.coordinator)
51
73
  return isCoordinatorHandoverLevel(event.level) ? REFERENCE.coordinatorHandover : null
74
+ if (event.kind === WATCH_EVENT.laneFill) return laneFillReference(event.level)
75
+ if (event.kind === WATCH_EVENT.approval)
76
+ return event.tool !== undefined && isStageTool(event.tool) ? REFERENCE.stageApproval : null
52
77
  return EVENT_REFERENCES[event.kind] ?? null
53
78
  }
54
79
 
80
+ /** The tools of `guard.approvals.standing`, the calls a standing approval may let through; none without one. */
81
+ export const stageToolsOf = (standing: { tools: Readonly<Record<string, string>> } | undefined): string[] =>
82
+ Object.keys(standing?.tools ?? {})
83
+
55
84
  /**
56
85
  * The places the orchestrate skill is installed, the project's first: `<root>/.claude/skills/<prefix>orchestrate`, and
57
86
  * the global copy `~/.claude/skills/<globalPrefix>orchestrate` that `jh sync --global` writes (`toGlobalKit`). The
@@ -84,6 +113,18 @@ export const referenceHint = (reference: Reference, skillDirectory: string | nul
84
113
  ? ` · read ${ORCHESTRATE_SKILL_NAME} skill ${reference}`
85
114
  : ` · read ${skillDirectory}/${reference}`
86
115
 
116
+ /** A hint as a line of its own, without its leading separator; no line for an empty hint. */
117
+ export const referenceLine = (hint: string): string[] => (hint === '' ? [] : [hint.trimStart()])
118
+
87
119
  /** `lines` with `hint` on the first one, the header of a multi-line event. */
88
120
  export const annotateFirstLine = (lines: readonly string[], hint: string): string[] =>
89
121
  lines.map((line, index) => (index === 0 ? `${line}${hint}` : line))
122
+
123
+ /** `lines` with `hint` on each of them, the duty lines that all name the same reference. */
124
+ export const annotateEach = (lines: readonly string[], hint: string): string[] => lines.map((line) => `${line}${hint}`)
125
+
126
+ /** `lane added` and `lane removed` lines, the hint on the removed ones only: an added lane is handled in the core. */
127
+ export const laneSetEventLines = ({ added, removed }: LaneSetChange, hint: string): string[] => [
128
+ ...laneSetLines({ added, removed: [] }),
129
+ ...annotateEach(laneSetLines({ added: [], removed }), hint),
130
+ ]
@@ -8,9 +8,12 @@ const CODE_FENCE = /^\s*(```|~~~)/
8
8
  const LIST_ITEM = /^(?:\d+[.)]|[-*])\s/
9
9
  const CONTINUATION_LINE = /^\s/
10
10
  const LIST_MARKER = /^(?:\d+[.)]|[-*])\s*/
11
- /** What a section writes when it has nothing to ask: "Keine.", "None.", "Keine offenen Fragen.", "Keine offenen.", a dash. */
11
+ /**
12
+ * What a section writes when it has nothing to ask: a sentence that opens with "Keine"/"None" and asks nothing
13
+ * ("Keine Rückfragen.", "None, all decided."), the short forms "No questions." / "Nein.", "n/a", a dash.
14
+ */
12
15
  const NO_QUESTIONS =
13
- /^(?:(?:keine|none|no|nein)(?:\s+(?:(?:offen(?:en?)?|open)(?:\s+ones?)?|(?:offenen\s+)?(?:fragen?|questions?)))?\.?|n\/a|[—–])$/i
16
+ /^(?:(?:keine|none)\b[^?]*|(?:no|nein)(?:\s+(?:(?:offen(?:en?)?|open)(?:\s+ones?)?|(?:offenen\s+)?(?:fragen?|questions?)))?\.?|n\/a|[—–])$/i
14
17
 
15
18
  /** What the questions part of a message may take: whole up to these limits, else cut question by question. */
16
19
  const QUESTION_LINES = 20