jackherd 1.3.0 → 1.5.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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +107 -37
  3. package/dist/{chunk-UI3AZNZF.js → chunk-HQAONP4W.js} +2 -0
  4. package/dist/config.d.ts +6 -0
  5. package/dist/config.js +23 -1
  6. package/dist/plan-format.d.ts +2 -0
  7. package/dist/plan-format.js +1 -1
  8. package/herdr-plugin/herdr-plugin.toml +1 -1
  9. package/install.sh +1 -1
  10. package/kit/hooks/hooks/ask-hint/decision.ts +28 -0
  11. package/kit/hooks/hooks/ask-hint/index.ts +44 -0
  12. package/kit/hooks/hooks/ask-hint/settings.ts +21 -0
  13. package/kit/hooks/hooks/ask-hint/transcript.ts +55 -0
  14. package/kit/hooks/hooks/merge-guard/check-free.ts +70 -0
  15. package/kit/hooks/hooks/merge-guard/decision.ts +2 -1
  16. package/kit/hooks/hooks/merge-guard/gh.ts +12 -0
  17. package/kit/hooks/hooks/merge-guard/pull-request.ts +27 -10
  18. package/kit/hooks/hooks/merge-guard/run.ts +22 -8
  19. package/kit/hooks/hooks/merge-guard/settings.ts +20 -4
  20. package/kit/hooks/hooks.json +8 -0
  21. package/kit/workflow/skills/follow/SKILL.md +10 -3
  22. package/kit/workflow/skills/orchestrate/SKILL.md +17 -6
  23. package/package.json +1 -1
  24. package/schemas/jackherd.schema.json +26 -0
  25. package/src/config/schema.ts +35 -0
  26. package/src/plans/lane-standing.ts +59 -0
  27. package/src/plans/layout.ts +32 -12
  28. package/src/plans/texts.ts +5 -1
  29. package/src/sync/render.ts +6 -1
  30. package/src/vocabulary/index.ts +4 -0
  31. package/src/watch/command.ts +20 -1
  32. package/src/watch/heartbeat-progress.ts +47 -0
  33. package/src/watch/source-command.ts +16 -0
  34. package/src/watch/source-run.ts +8 -1
  35. package/src/watch/stream-output.ts +20 -3
@@ -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
  }