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.
- package/CHANGELOG.md +15 -0
- package/README.md +109 -38
- package/dist/{chunk-UI3AZNZF.js → chunk-QJSXHHQ3.js} +15 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +27 -2
- package/dist/plan-format.d.ts +14 -1
- package/dist/plan-format.js +5 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- 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/rules/rules/kit/code-style.md +1 -0
- package/kit/workflow/skills/follow/SKILL.md +10 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +26 -9
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +23 -0
- package/src/cli.ts +15 -2
- package/src/config/detect.ts +15 -0
- package/src/config/schema.ts +31 -1
- 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/emptied-directories.ts +19 -0
- package/src/sync/project-fs.ts +28 -2
- package/src/sync/render.ts +14 -5
- package/src/sync/sync.ts +28 -10
- package/src/vocabulary/index.ts +24 -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/sync/project-fs.ts
CHANGED
|
@@ -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
|
package/src/sync/render.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { JackherdConfig } from '../config'
|
|
2
|
-
import {
|
|
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<
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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> =>
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -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'] },
|
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
|
}
|