jackherd 1.4.0 → 1.6.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 (36) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +109 -38
  3. package/dist/{chunk-UI3AZNZF.js → chunk-QJSXHHQ3.js} +15 -0
  4. package/dist/config.d.ts +7 -0
  5. package/dist/config.js +27 -2
  6. package/dist/plan-format.d.ts +14 -1
  7. package/dist/plan-format.js +5 -1
  8. package/herdr-plugin/herdr-plugin.toml +1 -1
  9. package/install.sh +1 -1
  10. package/kit/hooks/hooks/merge-guard/check-free.ts +70 -0
  11. package/kit/hooks/hooks/merge-guard/decision.ts +2 -1
  12. package/kit/hooks/hooks/merge-guard/gh.ts +12 -0
  13. package/kit/hooks/hooks/merge-guard/pull-request.ts +27 -10
  14. package/kit/hooks/hooks/merge-guard/run.ts +22 -8
  15. package/kit/hooks/hooks/merge-guard/settings.ts +20 -4
  16. package/kit/rules/rules/kit/code-style.md +1 -0
  17. package/kit/workflow/skills/follow/SKILL.md +10 -3
  18. package/kit/workflow/skills/orchestrate/SKILL.md +26 -9
  19. package/package.json +1 -1
  20. package/schemas/jackherd.schema.json +23 -0
  21. package/src/cli.ts +15 -2
  22. package/src/config/detect.ts +15 -0
  23. package/src/config/schema.ts +31 -1
  24. package/src/plans/lane-standing.ts +59 -0
  25. package/src/plans/layout.ts +32 -12
  26. package/src/plans/texts.ts +5 -1
  27. package/src/sync/emptied-directories.ts +19 -0
  28. package/src/sync/project-fs.ts +28 -2
  29. package/src/sync/render.ts +14 -5
  30. package/src/sync/sync.ts +28 -10
  31. package/src/vocabulary/index.ts +24 -0
  32. package/src/watch/command.ts +20 -1
  33. package/src/watch/heartbeat-progress.ts +47 -0
  34. package/src/watch/source-command.ts +16 -0
  35. package/src/watch/source-run.ts +8 -1
  36. package/src/watch/stream-output.ts +20 -3
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto'
2
- import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
3
- import { basename, dirname, join, sep } from 'node:path'
2
+ import { lstat, mkdir, readdir, readFile, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises'
3
+ import { basename, dirname, join, normalize, sep } from 'node:path'
4
4
  import { CLAUDE_DIRECTORY } from '../claude/paths'
5
5
  import { TOOL_DIRECTORY } from '../config/location'
6
6
  import { checksum } from './checksum'
@@ -86,6 +86,32 @@ export const removeFileSafely = async (directory: string, path: string): Promise
86
86
  if (state.kind === 'file') await rm(join(directory, path))
87
87
  }
88
88
 
89
+ export const DIRECTORY_OUTCOME = { removed: 'removed', notEmpty: 'notEmpty', skipped: 'skipped' } as const
90
+
91
+ export type DirectoryOutcome = (typeof DIRECTORY_OUTCOME)[keyof typeof DIRECTORY_OUTCOME]
92
+
93
+ /**
94
+ * Removes a real, empty directory and nothing else: a link, a file or a directory with any entry (a dotfile
95
+ * included) stays, and so does the directory itself that `path` names as `.`, and `rmdir` itself refuses what turned out not to be empty between the check and the call.
96
+ */
97
+ export const removeEmptyDirectory = async (directory: string, path: string): Promise<DirectoryOutcome> => {
98
+ if (normalize(path) === '.') return DIRECTORY_OUTCOME.skipped
99
+ // `hasPlainAncestors` looks at the directories above a file path, so a child segment stands in for one.
100
+ if (!(await hasPlainAncestors(directory, `${path}/entry`))) return DIRECTORY_OUTCOME.skipped
101
+ const target = join(directory, path)
102
+ const entry = await lstatOrMissing(target)
103
+ if (!entry?.isDirectory()) return DIRECTORY_OUTCOME.skipped
104
+ if ((await readdir(target)).length > 0) return DIRECTORY_OUTCOME.notEmpty
105
+ try {
106
+ await rmdir(target)
107
+ return DIRECTORY_OUTCOME.removed
108
+ } catch (err) {
109
+ if (typeof err === 'object' && err !== null && 'code' in err && (err.code === 'ENOTEMPTY' || err.code === 'EEXIST'))
110
+ return DIRECTORY_OUTCOME.notEmpty
111
+ throw err
112
+ }
113
+ }
114
+
89
115
  /** Creates the file only if nothing is at the path yet; returns false and writes nothing otherwise. */
90
116
  export const createFileExclusively = async (directory: string, path: string, content: string): Promise<boolean> => {
91
117
  if ((await readLocalState(directory, path)).kind !== 'missing') return false
@@ -1,5 +1,6 @@
1
1
  import type { JackherdConfig } from '../config'
2
- import { LANGUAGE_NAME, PLAN_VOCABULARY, SELF_MERGE_LABEL } from '../vocabulary'
2
+ import { frozenInstallCommand } from '../config/detect'
3
+ import { ADDRESS_FORM, LANGUAGE_NAME, PLAN_VOCABULARY, SELF_MERGE_LABEL } from '../vocabulary'
3
4
 
4
5
  const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
5
6
 
@@ -12,14 +13,19 @@ const laneCopyCommand = (copy: readonly string[]): string =>
12
13
  ? WORKTREE_INCLUDE_COPY
13
14
  : `cd <main-checkout> && cp -p --parents -- ${copy.map((path) => `'${path}'`).join(' ')} <worktree>/`
14
15
 
16
+ /** The patterns a pull request may match to be merged without checks, as prose for a skill; `none` for an empty list. */
17
+ const checkFreeList = (patterns: readonly string[]): string =>
18
+ patterns.length === 0 ? 'none' : patterns.map((pattern) => `\`${pattern}\``).join(', ')
19
+
15
20
  const prefixed = (prefix: string, values: Record<string, string>): [string, string][] =>
16
21
  Object.entries(values).map(([key, value]) => [`kit.${prefix}.${key}`, value])
17
22
 
18
23
  /** Every placeholder a kit file may use, with its value for this project. */
19
24
  const placeholderValues = (config: JackherdConfig): Map<string, string> => {
20
- const { header, sections, columns, phase } = PLAN_VOCABULARY[config.planLanguage]
25
+ const { header, sections, columns, phase, laneClosed } = PLAN_VOCABULARY[config.planLanguage]
21
26
  return new Map([
22
27
  ['kit.responseLanguage', LANGUAGE_NAME[config.responseLanguage]],
28
+ ['kit.responseAddress', ADDRESS_FORM[config.responseLanguage][config.responseAddress]],
23
29
  ['kit.planLanguage', LANGUAGE_NAME[config.planLanguage]],
24
30
  ['kit.planDirectory', config.planDirectory],
25
31
  ['kit.baseBranch', config.baseBranch],
@@ -28,9 +34,12 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
28
34
  ...prefixed('command', config.commands),
29
35
  ['kit.lanes.copyCommand', laneCopyCommand(config.lanes.copy)],
30
36
  ['kit.lanes.lockFile', config.lanes.lockFile],
37
+ ['kit.lanes.verifyInstall', frozenInstallCommand(config.commands.install)],
38
+ ['kit.word.laneClosed', laneClosed],
31
39
  ...prefixed('board', config.board),
32
40
  ['kit.guard.selfMergeLabel', SELF_MERGE_LABEL[config.responseLanguage]],
33
41
  ['kit.guard.selfMerge', config.guard.selfMerge.offer ? 'offered' : 'not offered'],
42
+ ['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
34
43
  ...prefixed('heading', { ...header, ...sections, ...columns, phase }),
35
44
  ])
36
45
  }
@@ -41,9 +50,9 @@ export const listPlaceholders = (template: string): string[] =>
41
50
 
42
51
  /**
43
52
  * Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
44
- * `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile>}}`,
45
- * `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.heading.<key>}}` from the plan
46
- * vocabulary, the two languages, the plan and worktree directory and the base branch. Other
53
+ * `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word.laneClosed}}`,
54
+ * `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
55
+ * vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. Other
47
56
  * `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
48
57
  * file unrendered. `overrides` win over the config: a global kit renders what each repository decides as
49
58
  * `<name>` (`REPOSITORY_TOKENS`).
package/src/sync/sync.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { homedir } from 'node:os'
2
- import { join } from 'node:path'
2
+ import { basename, dirname, join } from 'node:path'
3
3
  import { CLAUDE_DIRECTORY, SETTINGS_FILE } from '../claude/paths'
4
4
  import type { JackherdConfig } from '../config'
5
5
  import type { RepositoryFacts } from '../config/detect'
@@ -7,19 +7,22 @@ import { CONFIG_FILE, MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
7
7
  import { readRepositoryFacts } from '../config/repository-facts'
8
8
  import { REPOSITORY_TOKENS } from '../config/repository-values'
9
9
  import { CLI_NAME } from '../version/release'
10
+ import { emptiedDirectories } from './emptied-directories'
10
11
  import { ensureGitignoreLines } from './gitignore'
11
12
  import { toGlobalKit, toProjectKit } from './global-kit'
12
13
  import type { KitSource } from './kit'
13
14
  import { kitNewPath, toolRootPath } from './kit-path'
14
15
  import { moveGlobalLegacyFiles, moveProjectLegacyFiles, removeEmptyLegacyDirectories } from './legacy-files'
15
16
  import { EMPTY_MANIFEST, type Manifest, parseManifest, serializeManifest } from './manifest'
16
- import { FILE_MODE, type FileEffect, type FilePlan, planFile, type RenderedFile } from './plan-file'
17
+ import { FILE_MODE, FILE_OUTCOME, type FileEffect, type FilePlan, planFile, type RenderedFile } from './plan-file'
17
18
  import { type HooksPlan, planHooks } from './plan-hooks'
18
19
  import {
19
20
  assertNotHomeDirectory,
20
21
  assertOwnDirectoriesInProject,
22
+ DIRECTORY_OUTCOME,
21
23
  readLocalState,
22
24
  readRegularFile,
25
+ removeEmptyDirectory,
23
26
  removeFileSafely,
24
27
  writeFileSafely,
25
28
  } from './project-fs'
@@ -225,6 +228,21 @@ const applyHooks = async (claudeDirectory: string, hooks: HooksPlan): Promise<vo
225
228
  if (hooks.kitNew.kind === 'remove') await removeFileSafely(claudeDirectory, SETTINGS_KIT_NEW)
226
229
  }
227
230
 
231
+ /** Takes away the empty directories the removed kit files left; one that holds anything else stays and is named. Housekeeping only: a directory it cannot remove is a note, never a failed sync. */
232
+ const cleanUpDirectories = async (locate: Locate, plan: SyncPlan): Promise<string[]> => {
233
+ const removed = plan.files.filter(({ outcome }) => outcome === FILE_OUTCOME.removed).map(({ path }) => path)
234
+ const notes: string[] = []
235
+ for (const path of emptiedDirectories(removed, Object.keys(plan.manifest.files))) {
236
+ // A directory is located through a file below it, the one thing `Locate` knows how to place.
237
+ const { directory, path: target } = locate(`${path}/entry`)
238
+ const name = `${basename(directory)}/${dirname(target)}`
239
+ const outcome = await removeEmptyDirectory(directory, dirname(target)).catch(() => null)
240
+ if (outcome === null) notes.push(`Could not remove ${name}; remove it by hand if it is empty.`)
241
+ if (outcome === DIRECTORY_OUTCOME.notEmpty) notes.push(`Kept ${name}: it holds files the kit did not write.`)
242
+ }
243
+ return notes
244
+ }
245
+
228
246
  /**
229
247
  * The manifest is written last: a run that dies halfway leaves files whose content equals the
230
248
  * kit version, which the next run adopts as unchanged instead of mistaking them for local edits.
@@ -232,12 +250,14 @@ const applyHooks = async (claudeDirectory: string, hooks: HooksPlan): Promise<vo
232
250
  const applyPlan = async (
233
251
  { claudeDirectory, toolDirectory, locate }: Pick<PlanInput, 'claudeDirectory' | 'toolDirectory' | 'locate'>,
234
252
  plan: SyncPlan,
235
- ): Promise<void> => {
253
+ ): Promise<string[]> => {
236
254
  for (const { path, effect } of effectsInOrder(plan.files)) await applyFileEffect(locate, path, effect)
255
+ const notes = await cleanUpDirectories(locate, plan)
237
256
  await applyHooks(claudeDirectory, plan.hooks)
238
257
  const manifestText = serializeManifest(plan.manifest)
239
258
  if (manifestText !== plan.manifestText)
240
259
  await writeFileSafely(toolDirectory, MANIFEST_FILE, manifestText, FILE_MODE.regular)
260
+ return notes
241
261
  }
242
262
 
243
263
  const syncResultOf = (plan: SyncPlan): SyncResult => ({
@@ -266,11 +286,9 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
266
286
  accept: request.accept,
267
287
  readHooks: projectHooks(claudeDirectory, request.kit, config),
268
288
  })
269
- if (!request.check) {
270
- await applyPlan(directories, plan)
271
- await ensureGitignoreLines(request.projectRoot)
272
- }
273
- return { result: { ...syncResultOf(plan), notes: [...moved, ...notes] }, config }
289
+ const keptDirectoryNotes = request.check ? [] : await applyPlan(directories, plan)
290
+ if (!request.check) await ensureGitignoreLines(request.projectRoot)
291
+ return { result: { ...syncResultOf(plan), notes: [...moved, ...notes, ...keptDirectoryNotes] }, config }
274
292
  }
275
293
 
276
294
  export const runSync = async (request: SyncRequest): Promise<SyncResult> =>
@@ -302,9 +320,9 @@ export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<
302
320
  readHooks: noHooks,
303
321
  overrides: REPOSITORY_TOKENS,
304
322
  })
305
- await applyPlan(directories, plan)
323
+ const keptDirectoryNotes = await applyPlan(directories, plan)
306
324
  await removeEmptyLegacyDirectories(claudeDirectory)
307
- return { result: { ...syncResultOf(plan), notes: moved }, config }
325
+ return { result: { ...syncResultOf(plan), notes: [...moved, ...keptDirectoryNotes] }, config }
308
326
  }
309
327
 
310
328
  export const runGlobalSync = async (request: GlobalSyncRequest): Promise<SyncResult> =>
@@ -8,6 +8,26 @@ export const LANGUAGE_NAME: Record<PlanLanguage, string> = {
8
8
  [PLAN_LANGUAGE.de]: 'German',
9
9
  }
10
10
 
11
+ export const RESPONSE_ADDRESS = { informal: 'informal', formal: 'formal' } as const
12
+
13
+ export type ResponseAddress = (typeof RESPONSE_ADDRESS)[keyof typeof RESPONSE_ADDRESS]
14
+
15
+ /**
16
+ * How a skill's reply addresses the user, as the words a kit rule gives the agent. German and the other languages
17
+ * that tell a familiar from a polite "you" name the pronoun and the possessive; English has one form and only
18
+ * differs in tone.
19
+ */
20
+ export const ADDRESS_FORM: Record<PlanLanguage, Record<ResponseAddress, string>> = {
21
+ [PLAN_LANGUAGE.en]: {
22
+ [RESPONSE_ADDRESS.informal]: 'the plain, direct “you”, without titles',
23
+ [RESPONSE_ADDRESS.formal]: 'a polite, businesslike “you”',
24
+ },
25
+ [PLAN_LANGUAGE.de]: {
26
+ [RESPONSE_ADDRESS.informal]: 'the familiar “du” (du, dir, dein), never “Sie”',
27
+ [RESPONSE_ADDRESS.formal]: 'the polite “Sie” (Sie, Ihnen, Ihr), never “du”',
28
+ },
29
+ }
30
+
11
31
  /**
12
32
  * The option of the pull request question that approves the PR and a merge by the coordinator once the checks are
13
33
  * green. The merge guard hook matches the user's answer against this exact text, so it is fixed per language and not
@@ -59,6 +79,8 @@ export type PlanVocabulary = {
59
79
  noReviewGate: string
60
80
  /** Marks a lane as closed when its name cell contains it. */
61
81
  laneClosed: string
82
+ /** Words a starts-after cell begins with when the lane may start at once. */
83
+ startsNow: readonly string[]
62
84
  /** Words that may stand between two phases of one entry (`P1 Rule und P2 Monitor`). */
63
85
  phaseConnectors: readonly string[]
64
86
  statusWords: Record<PlanStatus, StatusWord>
@@ -82,6 +104,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
82
104
  phase: 'Phase',
83
105
  noReviewGate: 'Kein Review-Gate',
84
106
  laneClosed: 'abgeräumt',
107
+ startsNow: ['sofort'],
85
108
  phaseConnectors: ['danach', 'und', 'dann', 'sowie'],
86
109
  statusWords: {
87
110
  done: { label: 'Done', leading: ['done'], anywhere: ['abgeschlossen'] },
@@ -109,6 +132,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
109
132
  phase: 'Phase',
110
133
  noReviewGate: 'No review gate',
111
134
  laneClosed: 'cleaned up',
135
+ startsNow: ['now', 'immediately'],
112
136
  phaseConnectors: ['then', 'and', 'after'],
113
137
  statusWords: {
114
138
  done: { label: 'Done', leading: ['done'], anywhere: ['completed'] },
@@ -46,6 +46,7 @@ import { clockLocaleOf, type PlansSettings, plansSettingsOf, worktreeOf } from '
46
46
  import { boardTexts } from '../plans/texts'
47
47
  import { defaultUpdateCheck } from '../sync/if-changed'
48
48
  import { herdrLaneStatusSource } from '../terminal/herdr'
49
+ import { AGENT_STATUS } from '../terminal/types'
49
50
  import { startedKit } from '../version/started-kit'
50
51
  import {
51
52
  type CoordinatorRead,
@@ -68,6 +69,7 @@ import {
68
69
  } from './feedback'
69
70
  import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
70
71
  import { answered, deployTargetOf, type GithubRead, readGithub, targetText } from './github-read'
72
+ import { currentPhaseCountsOf, type PhaseCount, progressNotes } from './heartbeat-progress'
71
73
  import { agentScreen, listAgents } from './herdr'
72
74
  import { endedByKitUpdate } from './kit-update'
73
75
  import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill'
@@ -216,6 +218,8 @@ type Watching = {
216
218
  state: WatchState
217
219
  baseline: LaneSnapshot
218
220
  current: LaneSnapshot
221
+ /** The phase count of each lane at the last heartbeat, so a heartbeat names only a count that moved. */
222
+ heartbeatCounts: Record<string, PhaseCount>
219
223
  /** The last `herdr agent list` answer, kept for finding the coordinator by its pane. */
220
224
  agents: HerdrAgent[]
221
225
  github: GithubRead | null
@@ -762,6 +766,20 @@ const report = async (
762
766
  return shown
763
767
  }
764
768
 
769
+ /**
770
+ * The heartbeat of a stream: one line, a working lane with its phase count when that moved since the last heartbeat.
771
+ * The plans are read only when a lane works; a board that cannot be read leaves the lines without a count, and the
772
+ * next report names the failure.
773
+ */
774
+ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: number): Promise<void> => {
775
+ const working = watching.lanes.filter((lane) => watching.current[lane]?.status === AGENT_STATUS.working)
776
+ const { progress } = working.length > 0 ? await boardReadOrNote(watch, working) : { progress: {} }
777
+ const counts = currentPhaseCountsOf(progress)
778
+ const notes = progressNotes(watching.lanes, watching.current, counts, watching.heartbeatCounts)
779
+ watching.heartbeatCounts = { ...watching.heartbeatCounts, ...counts }
780
+ console.log(streamHeartbeatLine(clock(watch, now), watching.lanes, watching.current, notes))
781
+ }
782
+
765
783
  /**
766
784
  * One poll: reports what is due and tells whether to keep watching. Without `--stream` the first
767
785
  * report ends the watch; in a stream every reported lane counts as seen and the heartbeat is one line.
@@ -784,7 +802,7 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
784
802
  } else if (heartbeat && !stream) {
785
803
  next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }], { ...next, statusLines: null })
786
804
  } else {
787
- if (heartbeat) console.log(streamHeartbeatLine(clock(watch, now), watching.lanes, watching.current))
805
+ if (heartbeat) await printStreamHeartbeat(watch, watching, now)
788
806
  if (due) writePrivate(watch.files.state, JSON.stringify(next))
789
807
  watching.baseline = advanceBaseline(watching.baseline, watching.current)
790
808
  }
@@ -809,6 +827,7 @@ const initialWatching = (
809
827
  agents: start.agents,
810
828
  github: null,
811
829
  laneBranches: {},
830
+ heartbeatCounts: {},
812
831
  githubReadAt: 0,
813
832
  coordinator: { name: watch.options.coordinator, read: null },
814
833
  herdrDown: false,
@@ -0,0 +1,47 @@
1
+ import { currentPhase, type LaneProgress, PHASE_ID_PREFIX, type PhaseProgress } from '../plan-format'
2
+ import { AGENT_STATUS } from '../terminal/types'
3
+ import type { LaneSnapshot } from './lane-watch'
4
+
5
+ /** The current phase of a lane and how many of its checkboxes are ticked, as the plans view counts them. */
6
+ export type PhaseCount = Pick<PhaseProgress, 'id' | 'done' | 'total'>
7
+
8
+ /** What a heartbeat adds to a working lane, by lane name: `P12 7/12`, without the middle dot of `shortTitle`, which the heartbeat's separator rewrite would turn into a slash. */
9
+ export type LaneNotes = Readonly<Record<string, string>>
10
+
11
+ /** The count of the lane's current phase; null without a plan, without a current phase or with nothing to tick. */
12
+ export const currentPhaseCountOf = (progress: LaneProgress | null): PhaseCount | null => {
13
+ const phase = progress ? currentPhase(progress) : null
14
+ return phase && phase.total > 0 ? { id: phase.id, done: phase.done, total: phase.total } : null
15
+ }
16
+
17
+ /** The counts of the lanes that have one. */
18
+ export const currentPhaseCountsOf = (progress: Readonly<Record<string, LaneProgress>>): Record<string, PhaseCount> =>
19
+ Object.fromEntries(
20
+ Object.entries(progress).flatMap(([lane, laneProgress]) => {
21
+ const count = currentPhaseCountOf(laneProgress)
22
+ return count ? [[lane, count]] : []
23
+ }),
24
+ )
25
+
26
+ const sameCount = (left: PhaseCount | undefined, right: PhaseCount): boolean =>
27
+ left?.id === right.id && left.done === right.done && left.total === right.total
28
+
29
+ /**
30
+ * The note of each working lane whose count differs from the one at the last heartbeat (or has none before): a lane
31
+ * that has not moved says nothing, so a stretch on one number tells the coordinator to look for itself.
32
+ */
33
+ export const progressNotes = (
34
+ lanes: readonly string[],
35
+ current: LaneSnapshot,
36
+ counts: Readonly<Record<string, PhaseCount>>,
37
+ previous: Readonly<Record<string, PhaseCount>>,
38
+ ): LaneNotes =>
39
+ Object.fromEntries(
40
+ lanes.flatMap((lane) => {
41
+ const count = counts[lane]
42
+ const moved = count !== undefined && !sameCount(previous[lane], count)
43
+ return moved && current[lane]?.status === AGENT_STATUS.working
44
+ ? [[lane, `${PHASE_ID_PREFIX}${count.id} ${count.done}/${count.total}`]]
45
+ : []
46
+ }),
47
+ )
@@ -0,0 +1,16 @@
1
+ import { basename } from 'node:path'
2
+
3
+ const BUN_NAMES = new Set(['bun', 'bun.exe'])
4
+
5
+ /**
6
+ * The command a source starts. The watcher starts it without a shell, so `bun` would be looked up on the PATH, where
7
+ * Bun may be missing although the watcher itself runs under it (installed in `~/.bun/bin` only). `bun` as the program
8
+ * therefore means the Bun that runs the watcher (`execPath`); a path written out, another program, or a watcher that
9
+ * runs under something else is left as written.
10
+ */
11
+ export const resolveSourceCommand = (command: readonly string[], execPath: string): string[] => {
12
+ const [program, ...rest] = command
13
+ return program === 'bun' && BUN_NAMES.has(basename(execPath.replaceAll('\\', '/')))
14
+ ? [execPath, ...rest]
15
+ : [...command]
16
+ }
@@ -1,3 +1,4 @@
1
+ import { resolveSourceCommand } from './source-command'
1
2
  import { exitFailure, parseSourceOutput, SOURCE_FAILURE, type SourceRun, type SourceSettings } from './sources'
2
3
 
3
4
  /** How long a source's command may run and how much it may print before it is stopped. */
@@ -39,7 +40,13 @@ const readCapped = async (child: Child, maxBytes: number): Promise<string | null
39
40
  /** Started in a process group of its own, so a timeout reaches what the command started too (`curl` behind `sh`). */
40
41
  const spawned = (command: readonly string[], cwd: string): Child | null => {
41
42
  try {
42
- return Bun.spawn([...command], { cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'ignore', detached: true })
43
+ return Bun.spawn(resolveSourceCommand(command, process.execPath), {
44
+ cwd,
45
+ stdin: 'ignore',
46
+ stdout: 'pipe',
47
+ stderr: 'ignore',
48
+ detached: true,
49
+ })
43
50
  } catch {
44
51
  return null
45
52
  }
@@ -1,14 +1,31 @@
1
1
  import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { AGENT_STATUS } from '../terminal/types'
3
+ import type { LaneNotes } from './heartbeat-progress'
2
4
  import { type LaneSnapshot, NO_AGENT } from './lane-watch'
3
5
  import { WATCH_EVENT } from './watch-events'
4
6
 
7
+ const SEPARATOR = ' · '
8
+
9
+ /** `lane status`, a working lane with its note: `lane working: P12 7/12`. */
10
+ const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes): string => {
11
+ const status = current[lane]?.status ?? NO_AGENT
12
+ const note = status === AGENT_STATUS.working ? notes[lane] : undefined
13
+ const head = `${terminalSafe(lane)} ${terminalSafe(status)}`
14
+ return note === undefined ? head : `${head}: ${terminalSafe(note).replaceAll(SEPARATOR, ' / ')}`
15
+ }
16
+
5
17
  /**
6
18
  * The heartbeat of a stream, as one line, since every line of a stream reaches the coordinator as a
7
19
  * message of its own: `=== heartbeat <time> · <lane> <status> · …`.
8
20
  */
9
- export const streamHeartbeatLine = (time: string, lanes: readonly string[], current: LaneSnapshot): string => {
10
- const states = lanes.map((lane) => `${terminalSafe(lane)} ${terminalSafe(current[lane]?.status ?? NO_AGENT)}`)
21
+ export const streamHeartbeatLine = (
22
+ time: string,
23
+ lanes: readonly string[],
24
+ current: LaneSnapshot,
25
+ notes: LaneNotes,
26
+ ): string => {
27
+ const states = lanes.map((lane) => laneState(lane, current, notes))
11
28
  return [`=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`, ...(states.length > 0 ? states : ['no lanes'])].join(
12
- ' · ',
29
+ SEPARATOR,
13
30
  )
14
31
  }