jackherd 1.23.3 → 1.25.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 +9 -0
- package/INSTALL.md +16 -0
- package/README.md +23 -22
- package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
- package/dist/config.d.ts +13 -1
- package/dist/config.js +178 -132
- package/dist/plan-format.d.ts +121 -3
- package/dist/plan-format.js +176 -35
- package/docs/plan-contract.md +52 -5
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +2 -2
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
- package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
- package/kit/hooks/hooks/shared/boards.ts +22 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +2 -1
- package/kit/workflow/skills/orchestrate/references/backend.md +30 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +1 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
- package/kit/workflow/skills/orchestrate/references/start-lane.md +2 -2
- package/kit/workflow/skills/plan/SKILL.md +3 -1
- package/kit/workflow/skills/plan/references/template-English.md +2 -0
- package/kit/workflow/skills/plan/references/template-German.md +2 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +49 -0
- package/src/backend/command-line.ts +116 -0
- package/src/backend/command.ts +14 -0
- package/src/backend/control.ts +194 -0
- package/src/backend/handover.ts +28 -0
- package/src/backend/herdr.ts +147 -0
- package/src/backend/lock.ts +84 -0
- package/src/backend/process-cwd.ts +32 -0
- package/src/backend/schema.ts +47 -0
- package/src/backend/state.ts +57 -0
- package/src/backend/step.ts +93 -0
- package/src/backend/system.ts +150 -0
- package/src/backend/watch.ts +75 -0
- package/src/brief/command.ts +1 -1
- package/src/brief/lane-facts.ts +2 -2
- package/src/check/command.ts +2 -1
- package/src/check/plan-structure-check.ts +68 -14
- package/src/check/settings.ts +6 -1
- package/src/check/skill-budget-defaults.ts +6 -3
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +6 -0
- package/src/lane/close-board.ts +4 -4
- package/src/lane/close-command.ts +2 -2
- package/src/lane/dev-command.ts +1 -1
- package/src/lane/dev.ts +2 -2
- package/src/lane/port-command.ts +1 -1
- package/src/plan-contract/command.ts +2 -1
- package/src/plan-contract/contract.ts +30 -9
- package/src/plan-format/bare-codes.ts +4 -4
- package/src/plan-format/dependencies.ts +5 -5
- package/src/plan-format/lanes.ts +3 -4
- package/src/plan-format/markdown.ts +125 -10
- package/src/plan-format/open-status.ts +15 -0
- package/src/plan-format/phases.ts +45 -4
- package/src/plan-format/plan-head.ts +31 -9
- package/src/plan-format/plan-structure.ts +43 -7
- package/src/plan-format/progress.ts +3 -0
- package/src/plan-format/sprint-document.ts +4 -4
- package/src/plan-format/unrecognized-sections.ts +21 -0
- package/src/plan-format/waiting.ts +13 -13
- package/src/plans/board-close.ts +25 -16
- package/src/plans/board-pick.ts +1 -1
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/layout-summary.ts +8 -1
- package/src/plans/layout.ts +14 -5
- package/src/plans/live.ts +1 -1
- package/src/plans/plan-panes.ts +1 -1
- package/src/plans/settings.ts +6 -0
- package/src/plans/snapshot.ts +9 -5
- package/src/plans/sprint-export.ts +1 -1
- package/src/plans/texts.ts +3 -0
- package/src/plans/view.ts +1 -0
- package/src/resume/coordinator-read.ts +2 -2
- package/src/settled/command.ts +1 -1
- package/src/settled/settle.ts +5 -5
- package/src/shared/shell-word.ts +2 -0
- package/src/sync/render.ts +20 -5
- package/src/sync/sync-plan.ts +6 -2
- package/src/sync/sync.ts +3 -2
- package/src/vocabulary/index.ts +192 -4
- package/src/watch/appointments.ts +13 -20
- package/src/watch/command.ts +32 -5
- package/src/watch/lane-progress.ts +1 -1
- package/src/watch/merge-overlap-read.ts +2 -2
- package/src/watch/plan-missing-read.ts +3 -1
- package/src/watch/reference-paths.ts +4 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
2
|
+
import { BACKEND_PANE, type BackendPane, type BackendState } from './state'
|
|
3
|
+
|
|
4
|
+
/** At most this many automatic starts within `BACKEND_START_WINDOW_MS`. */
|
|
5
|
+
export const BACKEND_START_LIMIT = 3
|
|
6
|
+
export const BACKEND_START_WINDOW_MS = 10 * MS_PER_MINUTE
|
|
7
|
+
|
|
8
|
+
export const BACKEND_STEP_ACTION = { none: 'none', start: 'start', split: 'split' } as const
|
|
9
|
+
|
|
10
|
+
export type BackendStepAction = (typeof BACKEND_STEP_ACTION)[keyof typeof BACKEND_STEP_ACTION]
|
|
11
|
+
|
|
12
|
+
export const BACKEND_GIVE_UP_LINE = `backend: gave up after ${BACKEND_START_LIMIT} starts in ${BACKEND_START_WINDOW_MS / MS_PER_MINUTE} minutes; jh backend restart`
|
|
13
|
+
export const BACKEND_DIFFERS_LINE = 'backend: running command differs from lanes.backend; jh backend restart'
|
|
14
|
+
|
|
15
|
+
/** What the watcher found: the pane, where it sits, the busy service ports of the current list, the wanted command. */
|
|
16
|
+
export type BackendObservation = {
|
|
17
|
+
pane: BackendPane
|
|
18
|
+
/** The pane's id, or the tab label when the pane is still to be created; named in the start line. */
|
|
19
|
+
place: string
|
|
20
|
+
busyPorts: readonly number[]
|
|
21
|
+
/** The command line `backendCommandOf` gives; null when every service is released. */
|
|
22
|
+
desired: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type BackendStep = { action: BackendStepAction; state: BackendState; line: string | null }
|
|
26
|
+
|
|
27
|
+
const stay = (state: BackendState): BackendStep => ({ action: BACKEND_STEP_ACTION.none, state, line: null })
|
|
28
|
+
|
|
29
|
+
/** Reports `line` only when it differs from the one already standing. */
|
|
30
|
+
export const noticed = (state: BackendState, line: string): BackendStep => ({
|
|
31
|
+
action: BACKEND_STEP_ACTION.none,
|
|
32
|
+
state: { ...state, notice: line },
|
|
33
|
+
line: state.notice === line ? null : line,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const settled = (state: BackendState): BackendState => (state.notice === null ? state : { ...state, notice: null })
|
|
37
|
+
|
|
38
|
+
const runningStep = (state: BackendState, desired: string | null): BackendStep =>
|
|
39
|
+
desired === null || state.command === desired ? stay(settled(state)) : noticed(state, BACKEND_DIFFERS_LINE)
|
|
40
|
+
|
|
41
|
+
const portLine = (port: number): string => `backend: port ${port} in use, not started`
|
|
42
|
+
|
|
43
|
+
const started = (
|
|
44
|
+
state: BackendState,
|
|
45
|
+
observation: BackendObservation,
|
|
46
|
+
desired: string,
|
|
47
|
+
starts: number[],
|
|
48
|
+
): BackendStep => ({
|
|
49
|
+
action: observation.pane === BACKEND_PANE.missing ? BACKEND_STEP_ACTION.split : BACKEND_STEP_ACTION.start,
|
|
50
|
+
state: { ...state, command: desired, starts, notice: null },
|
|
51
|
+
line: `backend: started in ${observation.place}`,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const startStep = (state: BackendState, observation: BackendObservation, desired: string, now: number): BackendStep => {
|
|
55
|
+
const recent = state.starts.filter((start) => now - start < BACKEND_START_WINDOW_MS)
|
|
56
|
+
if (recent.length >= BACKEND_START_LIMIT)
|
|
57
|
+
return noticed({ ...state, starts: recent, gaveUp: true }, BACKEND_GIVE_UP_LINE)
|
|
58
|
+
return started(state, observation, desired, [...recent, now])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const refusedByHand = (state: BackendState, line: string): BackendStep => ({
|
|
62
|
+
action: BACKEND_STEP_ACTION.none,
|
|
63
|
+
state,
|
|
64
|
+
line,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The start `jh backend` makes after it stopped the pane (`restart`, `release`, `return`): no start limit, no start
|
|
69
|
+
* counted, every refusal said each time, since a person asked for it. A busy port prevents it as at every check.
|
|
70
|
+
*/
|
|
71
|
+
export const startedByHand = (state: BackendState, observation: BackendObservation): BackendStep => {
|
|
72
|
+
const { pane, desired, busyPorts, place } = observation
|
|
73
|
+
if (pane === BACKEND_PANE.running) return refusedByHand(state, `backend: still running in ${place}, not started`)
|
|
74
|
+
if (desired === null)
|
|
75
|
+
return refusedByHand({ ...state, command: null }, 'backend: every service is handed to a lane, nothing started')
|
|
76
|
+
const [busy] = busyPorts
|
|
77
|
+
if (busy !== undefined) return refusedByHand(state, portLine(busy))
|
|
78
|
+
return started(state, observation, desired, state.starts)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The decision of one check: what to do with the pane, the state to keep and the line to report, if any. A standing
|
|
83
|
+
* condition (a busy port, a differing command, the given-up state) is reported once, not at every check. Nothing here
|
|
84
|
+
* stops a process or frees a port: a busy port only prevents the start.
|
|
85
|
+
*/
|
|
86
|
+
export const backendStep = (state: BackendState, observation: BackendObservation, now: number): BackendStep => {
|
|
87
|
+
const { pane, desired, busyPorts } = observation
|
|
88
|
+
if (pane === BACKEND_PANE.running) return runningStep(state, desired)
|
|
89
|
+
if (desired === null || state.gaveUp) return stay(state)
|
|
90
|
+
const [busy] = busyPorts
|
|
91
|
+
if (busy !== undefined) return noticed(state, portLine(busy))
|
|
92
|
+
return startStep(state, observation, desired, now)
|
|
93
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
4
|
+
import { worktreeNames } from '../plans/git'
|
|
5
|
+
import { paneListOf } from '../plans/plan-panes'
|
|
6
|
+
import { listeningPorts } from '../plans/port-listening'
|
|
7
|
+
import { readPrivateText } from '../shared/private-location'
|
|
8
|
+
import { type HerdrRunner, renameArguments, runHerdr } from '../terminal/herdr'
|
|
9
|
+
import { ensureWatcherDirectory } from '../watch/watcher-lock'
|
|
10
|
+
import type { BackendSystem } from './control'
|
|
11
|
+
import {
|
|
12
|
+
type BackendSight,
|
|
13
|
+
backendSightOf,
|
|
14
|
+
createdPaneOf,
|
|
15
|
+
mainWorkspaceOf,
|
|
16
|
+
type PaneProcesses,
|
|
17
|
+
paneProcessesOf,
|
|
18
|
+
stopTargetOf,
|
|
19
|
+
tabIdsOf,
|
|
20
|
+
} from './herdr'
|
|
21
|
+
import { processCwdOf } from './process-cwd'
|
|
22
|
+
import type { BackendConfig } from './schema'
|
|
23
|
+
import { BACKEND_PANE, parseBackendState } from './state'
|
|
24
|
+
|
|
25
|
+
/** The backend's files in the watcher's directory of the repository, beside `watch-state.json`. */
|
|
26
|
+
export const BACKEND_FILES = { state: 'backend.json', lock: 'backend.lock' } as const
|
|
27
|
+
|
|
28
|
+
export type BackendFiles = Record<keyof typeof BACKEND_FILES, string>
|
|
29
|
+
|
|
30
|
+
type Context = { root: string; label: string; run: HerdrRunner }
|
|
31
|
+
|
|
32
|
+
type StopResult = { ok: true } | { ok: false; line: string }
|
|
33
|
+
|
|
34
|
+
/** How long `jh backend` waits for a stopped backend to free its pane and ports. */
|
|
35
|
+
const STOP_WAIT_MS = 30_000
|
|
36
|
+
const STOP_POLL_MS = 500
|
|
37
|
+
|
|
38
|
+
/** The paths of the backend's state and lock for the main checkout `root`; creates the private directory. */
|
|
39
|
+
export const backendFilesOf = (root: string): BackendFiles => {
|
|
40
|
+
const directory = ensureWatcherDirectory(root)
|
|
41
|
+
return { state: join(directory, BACKEND_FILES.state), lock: join(directory, BACKEND_FILES.lock) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const workspaceOf = ({ root, run }: Context): string => mainWorkspaceOf(run(['workspace', 'list']), root)
|
|
45
|
+
|
|
46
|
+
const processesOf = (context: Context, pane: string): PaneProcesses =>
|
|
47
|
+
paneProcessesOf(context.run(['pane', 'process-info', '--pane', pane]))
|
|
48
|
+
|
|
49
|
+
const lookWith = (context: Context): BackendSight =>
|
|
50
|
+
backendSightOf({
|
|
51
|
+
panes: paneListOf(context.run(['pane', 'list'])),
|
|
52
|
+
tabs: tabIdsOf(context.run(['tab', 'list', '--workspace', workspaceOf(context)])),
|
|
53
|
+
label: context.label,
|
|
54
|
+
root: context.root,
|
|
55
|
+
foregroundOf: (pane) => processesOf(context, pane).processes.map(({ argv }) => argv),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const busyPortsOf = async (ports: readonly number[]): Promise<number[]> => {
|
|
59
|
+
const listening = await listeningPorts(ports)
|
|
60
|
+
return ports.filter((port) => listening.has(port))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A new tab in the main checkout's workspace, without focus, working in `root`; its pane labelled and running `command`. */
|
|
64
|
+
const createWith = (context: Context, command: string): string => {
|
|
65
|
+
const { root, label, run } = context
|
|
66
|
+
const created = run([
|
|
67
|
+
'tab',
|
|
68
|
+
'create',
|
|
69
|
+
'--workspace',
|
|
70
|
+
workspaceOf(context),
|
|
71
|
+
'--cwd',
|
|
72
|
+
root,
|
|
73
|
+
'--label',
|
|
74
|
+
label,
|
|
75
|
+
'--no-focus',
|
|
76
|
+
])
|
|
77
|
+
const pane = createdPaneOf(created)
|
|
78
|
+
run(renameArguments(pane, label))
|
|
79
|
+
run(['pane', 'run', pane, command])
|
|
80
|
+
return pane
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isStopped = async (context: Context, ports: readonly number[]): Promise<boolean> => {
|
|
84
|
+
const sight = lookWith(context)
|
|
85
|
+
return sight.kind === 'found' && sight.pane !== BACKEND_PANE.running && (await busyPortsOf(ports)).length === 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** SIGTERM to `pid`; a process that ended since it was read is what the stop wanted, any other failure propagates. */
|
|
89
|
+
const terminate = (pid: number) => {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 'SIGTERM')
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const waitUntilStopped = async (context: Context, pane: string, pid: number, ports: readonly number[]) => {
|
|
98
|
+
const deadline = Date.now() + STOP_WAIT_MS
|
|
99
|
+
while (Date.now() < deadline) {
|
|
100
|
+
if (await isStopped(context, ports)) return { ok: true } as const
|
|
101
|
+
await Bun.sleep(STOP_POLL_MS)
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
line: `backend: still running ${STOP_WAIT_MS / 1000} s after SIGTERM to ${pid}; stop it in pane ${pane}`,
|
|
106
|
+
} as const
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* SIGTERM to the leader of the pane's foreground group once `stopTargetOf` accepted it (never the shell, only a
|
|
111
|
+
* process in the main checkout), then a wait until the pane is a shell again and the ports are free. The pane is read
|
|
112
|
+
* a second time after the directory check, and the signal goes out only when the same group still leads it, so a
|
|
113
|
+
* process id reused in between is not hit. Never a port kill.
|
|
114
|
+
*/
|
|
115
|
+
const stopWith = async (context: Context, pane: string, ports: readonly number[]): Promise<StopResult> => {
|
|
116
|
+
const target = stopTargetOf(processesOf(context, pane), context.root, (pid) => processCwdOf(pid), pane)
|
|
117
|
+
if (!target.ok) return target
|
|
118
|
+
if (processesOf(context, pane).groupId !== target.pid)
|
|
119
|
+
return { ok: false, line: `backend: the process in pane ${pane} changed while it was checked; try again` }
|
|
120
|
+
terminate(target.pid)
|
|
121
|
+
return waitUntilStopped(context, pane, target.pid, ports)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The backend's calls for the main checkout `root`: herdr through `run`, the state in `files`, the ports probed on
|
|
126
|
+
* this machine, the lanes' worktrees under `worktreeDirectory`.
|
|
127
|
+
*/
|
|
128
|
+
export const backendSystemOf = (input: {
|
|
129
|
+
root: string
|
|
130
|
+
worktreeDirectory: string
|
|
131
|
+
backend: BackendConfig
|
|
132
|
+
files: BackendFiles
|
|
133
|
+
run?: HerdrRunner
|
|
134
|
+
}): BackendSystem => {
|
|
135
|
+
const root = realpathSync(input.root)
|
|
136
|
+
const context: Context = { root, label: input.backend.label, run: input.run ?? runHerdr }
|
|
137
|
+
return {
|
|
138
|
+
readState: () => parseBackendState(readPrivateText(input.files.state)),
|
|
139
|
+
writeState: (state) => writePrivateFile(input.files.state, `${JSON.stringify(state)}\n`),
|
|
140
|
+
look: () => lookWith(context),
|
|
141
|
+
busyPorts: busyPortsOf,
|
|
142
|
+
create: (command) => createWith(context, command),
|
|
143
|
+
run: (pane, command) => {
|
|
144
|
+
context.run(['pane', 'run', pane, command])
|
|
145
|
+
},
|
|
146
|
+
stop: (pane, ports) => stopWith(context, pane, ports),
|
|
147
|
+
worktrees: () => worktreeNames(join(root, input.worktreeDirectory)),
|
|
148
|
+
now: Date.now,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readConfig } from '../config/read-config'
|
|
2
|
+
import type { JackherdConfig } from '../config/schema'
|
|
3
|
+
import { errorText } from '../plans/error-text'
|
|
4
|
+
import { checkBackend } from './control'
|
|
5
|
+
import { withBackendLock } from './lock'
|
|
6
|
+
import type { BackendConfig } from './schema'
|
|
7
|
+
import { backendFilesOf, backendSystemOf } from './system'
|
|
8
|
+
|
|
9
|
+
/** The watcher's memory of the backend between polls: the last check and whether a failure was already said. */
|
|
10
|
+
export type BackendWatch = { checkedAt: number | null; failureReported: boolean }
|
|
11
|
+
|
|
12
|
+
export type BackendWatchEffects = {
|
|
13
|
+
/** One check; null when `jh backend` holds the lock, so the next check tries again. */
|
|
14
|
+
check: () => Promise<string[] | null>
|
|
15
|
+
log: (line: string) => void
|
|
16
|
+
/** Put behind every line: where the coordinator reads what to do. */
|
|
17
|
+
hint: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The backend and the lanes' worktree directory, both from the main checkout's config. */
|
|
21
|
+
export type MainCheckoutBackend = { backend: BackendConfig; worktreeDirectory: string }
|
|
22
|
+
|
|
23
|
+
/** The watcher checks the backend pane at its start and then once a minute, not at every poll. */
|
|
24
|
+
export const BACKEND_CHECK_MS = 60_000
|
|
25
|
+
|
|
26
|
+
export const INITIAL_BACKEND_WATCH: BackendWatch = { checkedAt: null, failureReported: false }
|
|
27
|
+
|
|
28
|
+
export const backendCheckDue = ({ checkedAt }: BackendWatch, now: number): boolean =>
|
|
29
|
+
checkedAt === null || now - checkedAt >= BACKEND_CHECK_MS
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Checks the backend when one is due and prints its lines. A failure never ends the watch; it is printed once until
|
|
33
|
+
* a check works again, as `keepPlanViews` does for the herd panes.
|
|
34
|
+
*/
|
|
35
|
+
export const keepBackend = async (
|
|
36
|
+
watch: BackendWatch,
|
|
37
|
+
now: number,
|
|
38
|
+
effects: BackendWatchEffects,
|
|
39
|
+
): Promise<BackendWatch> => {
|
|
40
|
+
if (!backendCheckDue(watch, now)) return watch
|
|
41
|
+
try {
|
|
42
|
+
for (const line of (await effects.check()) ?? []) effects.log(`${line}${effects.hint}`)
|
|
43
|
+
return { checkedAt: now, failureReported: false }
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (!watch.failureReported) effects.log(`backend check failed, still watching: ${errorText(error)}${effects.hint}`)
|
|
46
|
+
return { checkedAt: now, failureReported: true }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `lanes.backend` and `worktreeDirectory` of the main checkout's config only: a lane's worktree must change neither
|
|
52
|
+
* what the watcher runs in the main checkout nor which lanes count as gone. `config` is reused when the watcher
|
|
53
|
+
* already read the main checkout; undefined without a backend or when that config cannot be read.
|
|
54
|
+
*/
|
|
55
|
+
export const mainCheckoutBackendOf = (
|
|
56
|
+
root: string,
|
|
57
|
+
checkoutRoot: string,
|
|
58
|
+
config: JackherdConfig,
|
|
59
|
+
): MainCheckoutBackend | undefined => {
|
|
60
|
+
try {
|
|
61
|
+
const main = checkoutRoot === root ? config : readConfig([root])
|
|
62
|
+
return main.lanes.backend ? { backend: main.lanes.backend, worktreeDirectory: main.worktreeDirectory } : undefined
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One check of the main checkout's backend under the lock, without waiting for it. */
|
|
69
|
+
export const backendCheckOf =
|
|
70
|
+
(root: string, { backend, worktreeDirectory }: MainCheckoutBackend) =>
|
|
71
|
+
(): Promise<string[] | null> => {
|
|
72
|
+
const files = backendFilesOf(root)
|
|
73
|
+
const system = backendSystemOf({ root, worktreeDirectory, backend, files })
|
|
74
|
+
return withBackendLock(files.lock, 0, () => checkBackend(backend, system))
|
|
75
|
+
}
|
package/src/brief/command.ts
CHANGED
|
@@ -35,7 +35,7 @@ export const run = async (args: string[]): Promise<number> => {
|
|
|
35
35
|
const here = worktreeRoot(process.cwd())
|
|
36
36
|
const settings = readSettings([here, root])
|
|
37
37
|
const picked = boardFor(boardPath, [root, here], settings, () => foundLanesOf(root, settings, () => []))
|
|
38
|
-
for (const line of briefLines(laneFactsOf(root, settings, picked.file, Date.now()), settings.
|
|
38
|
+
for (const line of briefLines(laneFactsOf(root, settings, picked.file, Date.now()), settings.boardLanguage))
|
|
39
39
|
console.log(line)
|
|
40
40
|
return 0
|
|
41
41
|
} catch (error) {
|
package/src/brief/lane-facts.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, join, relative } from 'node:path'
|
|
|
3
3
|
import { parseSprintLanes, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
4
4
|
import { type QuestionRecord } from '../plans/question-records'
|
|
5
5
|
import { type SentFileRecord, sentLaneOf } from '../plans/sent-files'
|
|
6
|
-
import type
|
|
6
|
+
import { type PlansSettings } from '../plans/settings'
|
|
7
7
|
import { laneDirectoryOf } from '../plans/unlisted-lanes'
|
|
8
8
|
import { readWaitingRecords } from '../plans/waiting-records'
|
|
9
9
|
import { isStillWaiting } from '../plans/waiting-window'
|
|
@@ -49,7 +49,7 @@ export const laneFactsOf = (root: string, settings: PlansSettings, boardFile: st
|
|
|
49
49
|
const sprintDirectory = dirname(relative(root, boardFile))
|
|
50
50
|
const worktreeDirectory = join(root, settings.worktreeDirectory)
|
|
51
51
|
const records = readWaitingRecords(root)
|
|
52
|
-
return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.
|
|
52
|
+
return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage)
|
|
53
53
|
.filter(({ closed }) => !closed)
|
|
54
54
|
.map((lane) => {
|
|
55
55
|
const href = planHrefOf(lane)
|
package/src/check/command.ts
CHANGED
|
@@ -223,7 +223,7 @@ const openBoxesCheckOf = (root: string, settings: CheckSettings): CheckResult |
|
|
|
223
223
|
lane,
|
|
224
224
|
planDirectory: settings.planDirectory,
|
|
225
225
|
board: settings.board,
|
|
226
|
-
language: settings.
|
|
226
|
+
language: settings.boardLanguage,
|
|
227
227
|
level: settings.check.openBoxes,
|
|
228
228
|
baseOf: contentAt(root, baseRefOf(settings)),
|
|
229
229
|
})
|
|
@@ -253,6 +253,7 @@ const ownChecksOf = (
|
|
|
253
253
|
paths,
|
|
254
254
|
planDirectory: settings.planDirectory,
|
|
255
255
|
language: settings.planLanguage,
|
|
256
|
+
boardLanguage: settings.boardLanguage,
|
|
256
257
|
previousOf: contentAt(root, 'HEAD'),
|
|
257
258
|
baseOf: contentAt(root, baseRefOf(settings)),
|
|
258
259
|
planRoots: planRootsOf(root, settings.worktreeDirectory),
|
|
@@ -13,12 +13,14 @@ import {
|
|
|
13
13
|
laneProgress,
|
|
14
14
|
longPhaseTitles,
|
|
15
15
|
mergedPhasesWithOpenBoxes,
|
|
16
|
+
newPhasesWithoutPurpose,
|
|
16
17
|
newPhasesWithoutReviewGate,
|
|
17
18
|
openBoxesOfPhase,
|
|
18
19
|
PHASE_CELL_LIMIT,
|
|
19
20
|
PHASE_TITLE_LIMIT,
|
|
20
21
|
type PhaseWithOpenBoxes,
|
|
21
22
|
type PhaseWithoutGate,
|
|
23
|
+
type PhaseWithoutPurpose,
|
|
22
24
|
type PhaseWithoutRow,
|
|
23
25
|
PLAN_VOCABULARY,
|
|
24
26
|
type PlanLanguage,
|
|
@@ -38,7 +40,10 @@ import {
|
|
|
38
40
|
shortNameFindings,
|
|
39
41
|
} from '../plan-format'
|
|
40
42
|
import { boardRowsWithBareCodes, type RowWithBareCode } from '../plan-format/bare-codes'
|
|
43
|
+
import { namesState } from '../plan-format/markdown'
|
|
44
|
+
import { firstUnrecognizedSectionLine } from '../plan-format/unrecognized-sections'
|
|
41
45
|
import { mergePlanPhases } from '../plans/phase-source'
|
|
46
|
+
import { CELL_KEY, PARTIAL_PHASE_WORDS, SECTION_KEY } from '../vocabulary'
|
|
42
47
|
import { changedPlansOf } from './deferral-arrows'
|
|
43
48
|
import { CHECK_STATUS, type CheckResult } from './result'
|
|
44
49
|
import { OPEN_BOXES_LEVEL, type OpenBoxesLevel } from './schema'
|
|
@@ -55,6 +60,8 @@ export type PlanCheckInput = {
|
|
|
55
60
|
paths: readonly string[]
|
|
56
61
|
planDirectory: string
|
|
57
62
|
language: PlanLanguage
|
|
63
|
+
/** The language the boards are read in first; `language` when absent. */
|
|
64
|
+
boardLanguage?: PlanLanguage
|
|
58
65
|
previousOf: ContentAt
|
|
59
66
|
baseOf: ContentAt
|
|
60
67
|
/** Where a board's plans are read, `root` when absent: the checkout, the main checkout and the lane worktrees. */
|
|
@@ -124,6 +131,16 @@ const gateReport = (language: PlanLanguage): Report<PhaseWithoutGate> => ({
|
|
|
124
131
|
clean: 'every new phase has a review gate',
|
|
125
132
|
})
|
|
126
133
|
|
|
134
|
+
const purposeReport = (language: PlanLanguage): Report<PhaseWithoutPurpose> => ({
|
|
135
|
+
name: 'Plan phase purposes',
|
|
136
|
+
noun: 'plan',
|
|
137
|
+
rule: 'new phases say what they are about',
|
|
138
|
+
unmet: CHECK_STATUS.fail,
|
|
139
|
+
text: ({ path, line, id, title }) =>
|
|
140
|
+
`${path}:${line} Phase ${id} ${title} has tasks but no **${PLAN_VOCABULARY[language].phasePurpose}:** line; say in one sentence what is different afterwards, for whom, directly under the heading`,
|
|
141
|
+
clean: 'every new phase says what it is about',
|
|
142
|
+
})
|
|
143
|
+
|
|
127
144
|
const rowReport: Report<RowWithoutPhase> = {
|
|
128
145
|
name: 'Board rows without a phase',
|
|
129
146
|
noun: 'board',
|
|
@@ -179,6 +196,21 @@ const unlistedReport: Report<PhaseWithoutRow> = {
|
|
|
179
196
|
clean: 'every phase has a row in the dependency table',
|
|
180
197
|
}
|
|
181
198
|
|
|
199
|
+
const sectionReport: Report<{ line: number }> = {
|
|
200
|
+
name: 'Plan sections',
|
|
201
|
+
noun: 'plan',
|
|
202
|
+
rule: 'jh reads at least one section',
|
|
203
|
+
unmet: CHECK_STATUS.warn,
|
|
204
|
+
text: ({ path, line }) =>
|
|
205
|
+
`${path}:${line} none of the ## headings is a section jh reads, so every reader finds the document empty; write one in a known language or put <!-- jh: <key> --> on the line directly under it (keys: ${Object.values(SECTION_KEY).join(', ')})`,
|
|
206
|
+
clean: 'jh reads a section of every changed plan and board',
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const sectionsOf = ({ path, current }: Changed): Found<{ line: number }>[] => {
|
|
210
|
+
const line = firstUnrecognizedSectionLine(current)
|
|
211
|
+
return line === null ? [] : [{ path, line }]
|
|
212
|
+
}
|
|
213
|
+
|
|
182
214
|
const shortNameText = (found: Found<ShortNameFinding>): string =>
|
|
183
215
|
found.problem === SHORT_NAME_PROBLEM.long
|
|
184
216
|
? `${found.path}:${found.line} Phase ${found.phase}: the short name has ${found.length} characters (limit ${SHORT_NAME_LIMIT})`
|
|
@@ -207,10 +239,15 @@ const withPath =
|
|
|
207
239
|
<T>(path: string) =>
|
|
208
240
|
(found: T): Found<T> => ({ ...found, path })
|
|
209
241
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
242
|
+
/** The ids of the phases the plan had before the change; none for a plan the change creates. */
|
|
243
|
+
const previousPhaseIds = ({ previous }: Changed, language: PlanLanguage): Set<string> =>
|
|
244
|
+
previous ? phaseIdsOfPlan(previous, language) : new Set()
|
|
245
|
+
|
|
246
|
+
const gatesOf = (file: Changed, language: PlanLanguage): Found<PhaseWithoutGate>[] =>
|
|
247
|
+
newPhasesWithoutReviewGate(file.current, language, previousPhaseIds(file, language)).map(withPath(file.path))
|
|
248
|
+
|
|
249
|
+
const purposesOf = (file: Changed, language: PlanLanguage): Found<PhaseWithoutPurpose>[] =>
|
|
250
|
+
newPhasesWithoutPurpose(file.current, language, previousPhaseIds(file, language)).map(withPath(file.path))
|
|
214
251
|
|
|
215
252
|
const rowsOf = ({ path, current, previousLines }: Changed, language: PlanLanguage): Found<RowWithoutPhase>[] =>
|
|
216
253
|
boardRowsWithoutPhase(current, language, previousLines).map(withPath(path))
|
|
@@ -219,17 +256,26 @@ const proseOf = ({ path, current, previousLines }: Changed, language: PlanLangua
|
|
|
219
256
|
boardRowsWithProse(current, language, previousLines).map(withPath(path))
|
|
220
257
|
|
|
221
258
|
/** Every readable copy of the plans the board links, in each plan root; a path that leaves the repository is skipped. */
|
|
222
|
-
const planTextsOf = (
|
|
223
|
-
|
|
259
|
+
const planTextsOf = (
|
|
260
|
+
roots: readonly string[],
|
|
261
|
+
{ path, current }: Changed,
|
|
262
|
+
languages: { board: PlanLanguage; plan: PlanLanguage },
|
|
263
|
+
): string[] =>
|
|
264
|
+
boardPlansOf(current, posix.normalize(dirname(path)), languages.board)
|
|
224
265
|
.filter(isInsideRepository)
|
|
225
266
|
.flatMap((plan) => planCopiesIn(roots, plan))
|
|
226
267
|
|
|
227
|
-
|
|
268
|
+
/** The board is read in the board language, the plans it links in the plan language. */
|
|
269
|
+
const bareCodesOfBoard = (
|
|
270
|
+
roots: readonly string[],
|
|
271
|
+
file: Changed,
|
|
272
|
+
languages: { board: PlanLanguage; plan: PlanLanguage },
|
|
273
|
+
): Found<RowWithBareCode>[] =>
|
|
228
274
|
boardRowsWithBareCodes(
|
|
229
275
|
file.current,
|
|
230
|
-
|
|
276
|
+
languages.board,
|
|
231
277
|
file.previousLines,
|
|
232
|
-
codesOfPlans(planTextsOf(roots, file,
|
|
278
|
+
codesOfPlans(planTextsOf(roots, file, languages), languages.plan),
|
|
233
279
|
).map(withPath(file.path))
|
|
234
280
|
|
|
235
281
|
const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<PhaseWithOpenBoxes>[] =>
|
|
@@ -297,7 +343,7 @@ const phaseBuiltBy = (input: LaneBoxesInput): BuiltPhase | null => {
|
|
|
297
343
|
if (!phase || !step?.planHref) return null
|
|
298
344
|
// with the base known, a phase this checkout did not tick further waits in the row; it is not what the lane builds
|
|
299
345
|
if (phase.work === undefined && input.baseOf(pathOf(step.planHref)) !== null) return null
|
|
300
|
-
const partial = step.label.
|
|
346
|
+
const partial = namesState(step.label, CELL_KEY.partialPhase, PARTIAL_PHASE_WORDS)
|
|
301
347
|
return { planPath: pathOf(step.planHref), phaseId: phase.id, partial }
|
|
302
348
|
}
|
|
303
349
|
|
|
@@ -341,7 +387,8 @@ export const laneOpenBoxesCheck = (input: LaneBoxesInput & { level: OpenBoxesLev
|
|
|
341
387
|
|
|
342
388
|
/**
|
|
343
389
|
* The structure of the changed plans and boards:
|
|
344
|
-
* - a phase the change added (against `HEAD`) with tasks and without a review gate fails
|
|
390
|
+
* - a phase the change added (against `HEAD`) with tasks and without a review gate fails, and so does one without a
|
|
391
|
+
* purpose line directly under its heading;
|
|
345
392
|
* - a lane row the change added or edited that names no phase warns, and so does one whose phases cell is longer
|
|
346
393
|
* than `PHASE_CELL_LIMIT` or records a side order of the user;
|
|
347
394
|
* - a waiting row the change added or edited whose item cell names a plan code of the board's plans bare warns;
|
|
@@ -357,15 +404,20 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
357
404
|
const files = changedPlansOf(input.paths, input.planDirectory)
|
|
358
405
|
if (files.length === 0) return []
|
|
359
406
|
const { language } = input
|
|
407
|
+
const boardLanguage = input.boardLanguage ?? language
|
|
360
408
|
const started = Date.now()
|
|
361
409
|
const changed = changedContents(input, files)
|
|
362
410
|
const boards = changed.filter(({ board }) => board)
|
|
363
411
|
const plans = changed.filter(({ board }) => !board)
|
|
364
412
|
const unlistedPerPlan = plans.map((file) => unlistedOf(file, language)).filter((found) => found !== null)
|
|
365
413
|
const gates = changed.flatMap((file) => gatesOf(file, language))
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
const
|
|
414
|
+
const purposes = changed.flatMap((file) => purposesOf(file, language))
|
|
415
|
+
const sections = changed.flatMap(sectionsOf)
|
|
416
|
+
const rows = boards.flatMap((file) => rowsOf(file, boardLanguage))
|
|
417
|
+
const prose = boards.flatMap((file) => proseOf(file, boardLanguage))
|
|
418
|
+
const bareCodes = boards.flatMap((file) =>
|
|
419
|
+
bareCodesOfBoard(input.planRoots ?? [input.root], file, { board: boardLanguage, plan: language }),
|
|
420
|
+
)
|
|
369
421
|
const merged = plans.flatMap((file) => mergedOf(file, language))
|
|
370
422
|
const unlisted = unlistedPerPlan.flat()
|
|
371
423
|
const shortNames = plans.flatMap((file) => shortNamesOf(file, language))
|
|
@@ -373,6 +425,7 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
373
425
|
const durationMs = Date.now() - started
|
|
374
426
|
return [
|
|
375
427
|
findingsResult(gateReport(language), gates, files.length, durationMs),
|
|
428
|
+
findingsResult(purposeReport(language), purposes, files.length, durationMs),
|
|
376
429
|
...(boards.length > 0
|
|
377
430
|
? [
|
|
378
431
|
findingsResult(rowReport, rows, boards.length, durationMs),
|
|
@@ -390,5 +443,6 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
390
443
|
findingsResult(titleReport, titles, plans.length, durationMs),
|
|
391
444
|
]
|
|
392
445
|
: []),
|
|
446
|
+
findingsResult(sectionReport, sections, files.length, durationMs),
|
|
393
447
|
]
|
|
394
448
|
}
|
package/src/check/settings.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type CheckSettings = {
|
|
|
21
21
|
check: CheckConfig
|
|
22
22
|
planDirectory: string
|
|
23
23
|
planLanguage: PlanLanguage
|
|
24
|
+
/** The language the boards are written in: the config's `boardLanguage`, or `planLanguage` when it sets none. */
|
|
25
|
+
boardLanguage: PlanLanguage
|
|
24
26
|
/** The branch on the remote whose plans count as merged. */
|
|
25
27
|
baseBranch: string
|
|
26
28
|
/** Where the lane worktrees live, below the main checkout: a board's plans are read there too. */
|
|
@@ -44,6 +46,7 @@ export const noStackFallbackOf = ({ stack, check, commands }: CheckSettings): No
|
|
|
44
46
|
const commandsSchema = jackherdConfigSchema.shape.commands
|
|
45
47
|
const planDirectorySchema = jackherdConfigSchema.shape.planDirectory
|
|
46
48
|
const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
|
|
49
|
+
const boardLanguageSchema = jackherdConfigSchema.shape.boardLanguage
|
|
47
50
|
const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
|
|
48
51
|
const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
|
|
49
52
|
const skillPrefixSchema = jackherdConfigSchema.shape.skillPrefix
|
|
@@ -58,11 +61,13 @@ export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckS
|
|
|
58
61
|
const detected = withDetected(z.record(z.string(), z.unknown()).parse(raw), facts)
|
|
59
62
|
const object = z.record(z.string(), z.unknown()).parse(detected.raw)
|
|
60
63
|
const { test, typecheck, lint } = commandsSchema.parse(object.commands)
|
|
64
|
+
const planLanguage = planLanguageSchema.parse(object.planLanguage)
|
|
61
65
|
return {
|
|
62
66
|
commands: { test, typecheck, lint },
|
|
63
67
|
check: checkConfigSchema.parse(object.check ?? {}),
|
|
64
68
|
planDirectory: planDirectorySchema.parse(object.planDirectory),
|
|
65
|
-
planLanguage
|
|
69
|
+
planLanguage,
|
|
70
|
+
boardLanguage: boardLanguageSchema.parse(object.boardLanguage) ?? planLanguage,
|
|
66
71
|
baseBranch: baseBranchSchema.parse(object.baseBranch),
|
|
67
72
|
worktreeDirectory: worktreeDirectorySchema.parse(object.worktreeDirectory),
|
|
68
73
|
board: boardSchema.parse(object.board),
|
|
@@ -74,7 +74,8 @@ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
|
|
|
74
74
|
'.claude/skills/orchestrate/references/waiting.md': 216,
|
|
75
75
|
'.claude/skills/orchestrate/references/watch-without-monitor.md': 154,
|
|
76
76
|
// Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
|
|
77
|
-
|
|
77
|
+
// Language split: the note that a template renders in the language of its file name and that a heading in any other language carries a marker (+27).
|
|
78
|
+
'.claude/skills/plan/SKILL.md': 1744,
|
|
78
79
|
'.claude/skills/plan/references/template-English.md': 536,
|
|
79
80
|
'.claude/skills/plan/references/template-German.md': 501,
|
|
80
81
|
// Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
|
|
@@ -111,9 +112,11 @@ export const KIT_SKILL_CEILINGS: Readonly<Record<string, number>> = {
|
|
|
111
112
|
'.claude/agents/skill-review.md': 513,
|
|
112
113
|
// Plan head: every plan opens with a `For you` head of five short lines a person reads first, which
|
|
113
114
|
// `jh plan check` reports; cut to its labels; skill-review: the status line says what `N` counts. Measured size (+42).
|
|
114
|
-
|
|
115
|
+
// Phase purpose: the line under every phase heading a template shows, one sentence of what is different afterwards and for whom (+17).
|
|
116
|
+
'.claude/skills/plan/references/template-English.md': 595,
|
|
115
117
|
// Plan head: the same `Für dich` head as the English template, with what `N` counts; measured size (+41).
|
|
116
|
-
|
|
118
|
+
// Phase purpose: the same line under every phase heading as the English template (+15).
|
|
119
|
+
'.claude/skills/plan/references/template-German.md': 557,
|
|
117
120
|
// P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
|
|
118
121
|
// Plain words: no plan code without its words in a short question.
|
|
119
122
|
// Any language: a pointer in § 0 and a section in events.md for `plan missing`.
|
package/src/cli.ts
CHANGED
|
@@ -19,6 +19,7 @@ const ALIASES = new Set<string>([HERD_COMMAND.alias])
|
|
|
19
19
|
/** One line per command; each entry lazily imports the module that exports its `run`. */
|
|
20
20
|
export const COMMANDS: CommandTable = {
|
|
21
21
|
approval: () => import('./approval/command'),
|
|
22
|
+
backend: () => import('./backend/command-line'),
|
|
22
23
|
brief: () => import('./brief/command'),
|
|
23
24
|
check: () => import('./check/command'),
|
|
24
25
|
config: () => import('./config/command'),
|
package/src/config/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { backendSchema } from '../backend/schema'
|
|
2
3
|
import { checkConfigSchema } from '../check/schema'
|
|
3
4
|
import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
|
|
4
5
|
import { SECRET_NAME } from '../secrets/options'
|
|
@@ -566,7 +567,10 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
566
567
|
responseLanguage: language.default(PLAN_LANGUAGE.en),
|
|
567
568
|
/** Whether replies address the user with the familiar or the polite form where the language has both (German: du or Sie). */
|
|
568
569
|
responseAddress: z.enum(Object.values(RESPONSE_ADDRESS)).default(RESPONSE_ADDRESS.informal),
|
|
570
|
+
/** The language new plans and the plan templates are written in. Boards and plans are read in every known language whatever this says. */
|
|
569
571
|
planLanguage: language.default(PLAN_LANGUAGE.en),
|
|
572
|
+
/** The language jh writes the board in (history lines, lane rows, waiting rows); `planLanguage` when left out. */
|
|
573
|
+
boardLanguage: language.optional(),
|
|
570
574
|
planDirectory: z.string().min(1).default('docs/plans'),
|
|
571
575
|
/** Where the boards live and what the project calls them. */
|
|
572
576
|
board: boardSchema.prefault({}),
|
|
@@ -607,6 +611,8 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
607
611
|
basePort: z.number().int().min(1).max(65535).optional(),
|
|
608
612
|
/** The command `jh lane dev` runs as `PORT=<port> <command>` in a pane beside the lane's agent; `{port}` and `{filter}` (`--filter <package>`) are filled in. Without it `jh lane dev` refuses. */
|
|
609
613
|
devCommand: z.string().min(1).optional(),
|
|
614
|
+
/** The services every lane's app talks to, run in one pane of the main checkout that the watcher starts and keeps; without it `jh backend` has nothing to run. */
|
|
615
|
+
backend: backendSchema.optional(),
|
|
610
616
|
})
|
|
611
617
|
.prefault({}),
|
|
612
618
|
models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
|