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