jackherd 1.18.2 → 1.19.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 +19 -0
- package/INSTALL.md +1 -1
- package/README.md +30 -25
- package/assets/icons/model.svg +1 -0
- package/dist/{chunk-N5FAOQZL.js → chunk-JDO6O3UO.js} +52 -2
- package/dist/config.d.ts +15 -2
- package/dist/config.js +22 -3
- package/dist/plan-format.d.ts +84 -1
- package/dist/plan-format.js +85 -1
- package/docs/plan-contract.md +8 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +10 -11
- package/kit/hooks/hooks/approval-gate/run.ts +23 -3
- package/kit/hooks/hooks/browser-guard/decision.ts +7 -0
- package/kit/hooks/hooks/browser-guard/index.ts +50 -0
- package/kit/hooks/hooks/plain-question/arguments.ts +14 -1
- package/kit/hooks/hooks/plain-question/card.ts +102 -0
- package/kit/hooks/hooks/plain-question/form.ts +25 -9
- package/kit/hooks/hooks/plain-question/index.ts +20 -25
- package/kit/hooks/hooks/plain-question/recommendation.ts +6 -6
- package/kit/hooks/hooks/return-brief/decision.ts +29 -0
- package/kit/hooks/hooks/return-brief/index.ts +37 -0
- package/kit/hooks/hooks/return-brief/settings.ts +11 -0
- package/kit/hooks/hooks/shared/coordinator-call.ts +25 -0
- package/kit/hooks/hooks/shared/transcript.ts +9 -0
- package/kit/hooks/hooks.json +18 -1
- package/kit/review/agents/security-audit.md +1 -1
- package/kit/workflow/agents/browser-check.md +29 -0
- package/kit/workflow/skills/implement/SKILL.md +5 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +36 -14
- package/kit/workflow/skills/orchestrate/references/acceptance.md +10 -9
- package/kit/workflow/skills/orchestrate/references/coordinator-clear.md +45 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +15 -12
- package/kit/workflow/skills/plan/SKILL.md +2 -0
- package/kit/workflow/skills/plan/references/template-English.md +8 -0
- package/kit/workflow/skills/plan/references/template-German.md +8 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +16 -0
- package/src/brief/command.ts +45 -0
- package/src/brief/lane-brief.ts +51 -0
- package/src/brief/lane-facts.ts +64 -0
- package/src/check/skill-budget-defaults.ts +13 -2
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +22 -0
- package/src/lane-prompt/texts.ts +2 -2
- package/src/plan-contract/command.ts +1 -0
- package/src/plan-contract/contract.ts +28 -5
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/plan-head.ts +131 -0
- package/src/plans/active-tasks.ts +8 -1
- package/src/plans/click-hint.ts +2 -1
- package/src/plans/focus-overlay.ts +5 -2
- package/src/plans/folded-detail.ts +47 -0
- package/src/plans/lane-line.ts +86 -0
- package/src/plans/lane-reason.ts +24 -4
- package/src/plans/layout-phase.ts +4 -20
- package/src/plans/layout-summary.ts +56 -45
- package/src/plans/layout.ts +178 -44
- package/src/plans/port-listening.ts +25 -0
- package/src/plans/selection.ts +11 -0
- package/src/plans/snapshot.ts +27 -9
- package/src/plans/task-checklist.ts +57 -0
- package/src/plans/texts.ts +34 -9
- package/src/resume/command.ts +40 -6
- package/src/resume/coordinator-read.ts +136 -0
- package/src/resume/state.ts +163 -0
- package/src/resume/texts.ts +6 -0
- package/src/sync/render.ts +5 -1
- package/src/vocabulary/index.ts +74 -0
- package/src/watch/clear-moment.ts +228 -0
- package/src/watch/command.ts +92 -8
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/reference-paths.ts +8 -4
- package/src/watch/watch-events.ts +13 -0
package/src/plans/texts.ts
CHANGED
|
@@ -42,19 +42,31 @@ export type PlanTexts = {
|
|
|
42
42
|
waitingHeading: string
|
|
43
43
|
backlogHeading: string
|
|
44
44
|
/** The balance of the lanes head: how many lanes wait on you, how many idle otherwise. */
|
|
45
|
-
|
|
45
|
+
lanesNeedYou: (lanes: number) => string
|
|
46
46
|
lanesIdle: (lanes: number) => string
|
|
47
47
|
/** On a resting lane's line: the labels before a question to you, a row of "Your turn" and its starts-after cell, and the line for a lane without any reason. */
|
|
48
48
|
restWaitsOnYou: string
|
|
49
|
-
/** After `restWaitsOnYou`: how many questions of its report wait,
|
|
49
|
+
/** After `restWaitsOnYou`: how many questions of its report wait, for a note that holds none of their words. */
|
|
50
50
|
restQuestions: (count: number) => string
|
|
51
|
+
/** After `restWaitsOnYou`: the first open question in its words and `+n questions` for the others. */
|
|
52
|
+
restAsks: (question: string, more: number) => string
|
|
53
|
+
/** `+2 phases`: the other phases that run beside the one the folded lane line names. */
|
|
54
|
+
morePhases: (count: number) => string
|
|
51
55
|
restWaitsOn: string
|
|
52
56
|
restAfter: string
|
|
53
57
|
restNoReason: string
|
|
54
58
|
/** On an open lane's session line: how long its agent has rested. */
|
|
55
59
|
idleFor: (minutes: number) => string
|
|
56
|
-
/** The
|
|
57
|
-
|
|
60
|
+
/** The status a folded lane line opens with: it needs the user, it is blocked on something, it runs. */
|
|
61
|
+
laneNeedsYou: string
|
|
62
|
+
laneBlocked: string
|
|
63
|
+
laneRunning: string
|
|
64
|
+
/** The age of the lane's last activity behind its status word: `3 min`, `2 h`, `<1 min`. */
|
|
65
|
+
laneAge: (minutes: number) => string
|
|
66
|
+
/** The row above and the row below a window over a long checklist: how many tasks it leaves out, `… 9 more`. */
|
|
67
|
+
tasksMore: (count: number) => string
|
|
68
|
+
/** The row that folds a long checklist listed whole back to its window. */
|
|
69
|
+
tasksListed: (count: number) => string
|
|
58
70
|
/** The row under an opened task's brief: how many lines its rest takes. */
|
|
59
71
|
moreLines: (count: number) => string
|
|
60
72
|
/** The footer while a row has the focus: what a click or a key does on it. */
|
|
@@ -145,16 +157,25 @@ export const TEXTS: PlanTexts = {
|
|
|
145
157
|
lanesHeading: 'Lanes',
|
|
146
158
|
waitingHeading: 'Waiting',
|
|
147
159
|
backlogHeading: 'Backlog',
|
|
148
|
-
|
|
160
|
+
lanesNeedYou: (lanes) => `${lanes} ${lanes === 1 ? 'needs' : 'need'} you`,
|
|
149
161
|
lanesIdle: (lanes) => `${lanes} idle`,
|
|
150
162
|
restWaitsOnYou: 'waits on you',
|
|
163
|
+
restAsks: (question, more) => `· ${question}${more > 0 ? ` +${more} ${more === 1 ? 'question' : 'questions'}` : ''}`,
|
|
164
|
+
morePhases: (count) => `+${count} ${count === 1 ? 'phase' : 'phases'}`,
|
|
151
165
|
restQuestions: (count) => `· ${count} ${count === 1 ? 'question' : 'questions'}`,
|
|
152
166
|
restWaitsOn: 'waits on',
|
|
153
167
|
restAfter: 'after',
|
|
154
168
|
restNoReason: 'idle · no reason',
|
|
155
169
|
idleFor: (minutes) => `idle ${minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h`}`,
|
|
156
|
-
|
|
157
|
-
|
|
170
|
+
laneNeedsYou: 'needs you',
|
|
171
|
+
laneBlocked: 'blocked',
|
|
172
|
+
laneRunning: 'running',
|
|
173
|
+
laneAge: (minutes) => {
|
|
174
|
+
if (minutes < 1) return '<1 min'
|
|
175
|
+
return minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h`
|
|
176
|
+
},
|
|
177
|
+
tasksMore: (count) => `… ${count} more`,
|
|
178
|
+
tasksListed: (count) => `${count} tasks`,
|
|
158
179
|
moreLines: (count) => `${count} more ${count === 1 ? 'line' : 'lines'}`,
|
|
159
180
|
clickHint: (action) => `${action} · ? help`,
|
|
160
181
|
hintOpens: (name) => `click or → opens ${name}`,
|
|
@@ -217,7 +238,10 @@ export const TEXTS: PlanTexts = {
|
|
|
217
238
|
{ mark: STATUS_SYMBOL[AGENT_STATUS.idle], meaning: 'idle' },
|
|
218
239
|
{ mark: `(${NO_AGENT_SYMBOL}) ${NO_AGENT_WORD}`, meaning: 'a worktree nobody works in' },
|
|
219
240
|
{ mark: UNKNOWN_SYMBOL, meaning: 'state unknown' },
|
|
220
|
-
{ mark: '
|
|
241
|
+
{ mark: 'needs you 3 min', meaning: 'the question to you, age of its last activity' },
|
|
242
|
+
{ mark: 'blocked 3 min', meaning: 'waits on a merge, checks or a go' },
|
|
243
|
+
{ mark: 'running 3 min', meaning: 'the step it is on' },
|
|
244
|
+
{ mark: ':3100 ↗', meaning: "opens the lane's dev server, while it listens" },
|
|
221
245
|
{ mark: '🥣 36 %', meaning: 'context used' },
|
|
222
246
|
{ mark: '⎇ ↑ ±', meaning: 'branch, commits ahead, uncommitted' },
|
|
223
247
|
{ mark: 'n/m ph', meaning: 'phases finished' },
|
|
@@ -228,7 +252,8 @@ export const TEXTS: PlanTexts = {
|
|
|
228
252
|
title: 'Tasks',
|
|
229
253
|
entries: [
|
|
230
254
|
{ mark: '● teal', meaning: 'running (reported), its text shimmers' },
|
|
231
|
-
{ mark: '◌
|
|
255
|
+
{ mark: '◌', meaning: 'running (guessed; the first open task of a running phase shimmers)' },
|
|
256
|
+
{ mark: '… n more', meaning: 'tasks the list leaves out; a click lists them all' },
|
|
232
257
|
{ mark: `${CHEVRON.closed} ${CHEVRON.open}`, meaning: 'opens / folds' },
|
|
233
258
|
{ mark: '☐ ☑', meaning: 'open, done' },
|
|
234
259
|
{ mark: '→ P9', meaning: 'moved to another phase' },
|
package/src/resume/command.ts
CHANGED
|
@@ -197,17 +197,51 @@ export const resumeAll = async (ports: ResumePorts): Promise<number> => {
|
|
|
197
197
|
return 0
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
export const RESUME_USAGE = `Usage: jh resume (run by the herdr plugin when herdr starts; takes no arguments)
|
|
201
|
+
jh resume --coordinator [--board <board.md>] what a coordinator takes up after /clear, from files and herdr
|
|
202
|
+
jh resume --clear-line [--board <board.md>] the first input of the chat after a clear, for the script's --send`
|
|
203
|
+
|
|
204
|
+
/** What the command line asks for; `usage` with whether that was a request for help or a wrong line. */
|
|
205
|
+
export type ResumeRequest =
|
|
206
|
+
| { kind: 'crash' }
|
|
207
|
+
| { kind: 'coordinator' | 'clearLine'; board: string | null }
|
|
208
|
+
| { kind: 'usage'; asked: boolean }
|
|
209
|
+
|
|
210
|
+
const MODES = { '--coordinator': 'coordinator', '--clear-line': 'clearLine' } as const
|
|
211
|
+
|
|
212
|
+
const isMode = (arg: string | undefined): arg is keyof typeof MODES => arg !== undefined && Object.hasOwn(MODES, arg)
|
|
213
|
+
|
|
214
|
+
/** Reads the command line. No arguments is the crash path, which herdr's hook calls and nothing here may change. */
|
|
215
|
+
export const parseResumeArgs = (args: readonly string[]): ResumeRequest => {
|
|
216
|
+
if (args.length === 0) return { kind: 'crash' }
|
|
217
|
+
if (args.includes('--help') || args.includes('-h')) return { kind: 'usage', asked: true }
|
|
218
|
+
const [mode, option, board, ...rest] = args
|
|
219
|
+
if (!isMode(mode) || rest.length > 0) return { kind: 'usage', asked: false }
|
|
220
|
+
if (option === undefined) return { kind: MODES[mode], board: null }
|
|
221
|
+
return option === '--board' && board !== undefined && !board.startsWith('--')
|
|
222
|
+
? { kind: MODES[mode], board }
|
|
223
|
+
: { kind: 'usage', asked: false }
|
|
224
|
+
}
|
|
225
|
+
|
|
200
226
|
/**
|
|
201
227
|
* `jh resume`: herdr's second `[[startup]]` hook. After a crash of the watcher (its record still marked running, no
|
|
202
228
|
* watcher holding the lock now) sends the coordinator one fixed `/orchestrate … ` line and each lane that worked one
|
|
203
229
|
* fixed text, at most once per record. Every step goes to `~/.jackherd/logs/resume.log`; the exit code is always 0,
|
|
204
|
-
* a hook leaves nothing behind but its log.
|
|
230
|
+
* a hook leaves nothing behind but its log. With `--coordinator` or `--clear-line` it reads and prints instead, for a
|
|
231
|
+
* coordinator that clears its context; exit 0, 1 on a wrong command line.
|
|
205
232
|
*/
|
|
206
233
|
export const run = async (args: string[]): Promise<number> => {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
234
|
+
const request = parseResumeArgs(args)
|
|
235
|
+
if (request.kind === 'usage') {
|
|
236
|
+
console.log(RESUME_USAGE)
|
|
237
|
+
return request.asked ? 0 : 1
|
|
210
238
|
}
|
|
211
|
-
|
|
212
|
-
|
|
239
|
+
if (request.kind === 'crash') {
|
|
240
|
+
const { systemResumePorts } = await import('./ports')
|
|
241
|
+
return resumeAll(systemResumePorts())
|
|
242
|
+
}
|
|
243
|
+
const { printClearLine, printCoordinatorState } = await import('./coordinator-read')
|
|
244
|
+
if (request.kind === 'clearLine') printClearLine(request.board)
|
|
245
|
+
else await printCoordinatorState(request.board)
|
|
246
|
+
return 0
|
|
213
247
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
import { installedReferenceHint } from '../lane/skill-hint'
|
|
4
|
+
import { defaultPromptFile } from '../lane-prompt/command'
|
|
5
|
+
import { parseSprintLanes } from '../plan-format'
|
|
6
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
7
|
+
import { boardFor, foundLanesOf } from '../plans/board-pick'
|
|
8
|
+
import { errorText } from '../plans/error-text'
|
|
9
|
+
import { mainCheckout, worktreeNames } from '../plans/git'
|
|
10
|
+
import { discoverLanes } from '../plans/lane-discovery'
|
|
11
|
+
import { type PlansSettings, readSettings, worktreeOf } from '../plans/settings'
|
|
12
|
+
import { recordWaitingItems } from '../plans/waiting-records'
|
|
13
|
+
import { readApprovalRequests } from '../watch/approvals'
|
|
14
|
+
import { readGithub } from '../watch/github-read'
|
|
15
|
+
import { listAgents } from '../watch/herdr'
|
|
16
|
+
import { laneReportPath, readLaneReport, reportMtime } from '../watch/lane-report-file'
|
|
17
|
+
import { branchOf } from '../watch/lane-sources'
|
|
18
|
+
import type { HerdrAgent } from '../watch/lane-watch'
|
|
19
|
+
import { REFERENCE, referenceLine } from '../watch/reference-paths'
|
|
20
|
+
import {
|
|
21
|
+
type CoordinatorStateInput,
|
|
22
|
+
coordinatorStateLines,
|
|
23
|
+
type LaneFacts,
|
|
24
|
+
laneStatusOf,
|
|
25
|
+
openPullRequestsOf,
|
|
26
|
+
} from './state'
|
|
27
|
+
import { resumeTexts } from './texts'
|
|
28
|
+
|
|
29
|
+
type Place = { root: string; settings: PlansSettings; boardFile: string }
|
|
30
|
+
|
|
31
|
+
/** The agents herdr lists; null when it does not answer. */
|
|
32
|
+
const readAgents = (): HerdrAgent[] | null => {
|
|
33
|
+
try {
|
|
34
|
+
return listAgents()
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const placeOf = (boardPath: string | null): Place => {
|
|
41
|
+
const root = mainCheckout(process.cwd())
|
|
42
|
+
const settings = readSettings([root])
|
|
43
|
+
const { file, notice } = boardFor(boardPath, [root], settings, () =>
|
|
44
|
+
foundLanesOf(root, settings, () => (readAgents() ?? []).flatMap((agent) => agent.name ?? [])),
|
|
45
|
+
)
|
|
46
|
+
if (notice) console.error(terminalSafe(notice))
|
|
47
|
+
return { root, settings, boardFile: file }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const readBoard = (file: string): string | null => {
|
|
51
|
+
try {
|
|
52
|
+
return readFileSync(file, 'utf8')
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const laneFactsOf = ({ root, settings }: Place, agents: readonly HerdrAgent[] | null, lane: string): LaneFacts => {
|
|
59
|
+
const reportPath = laneReportPath(worktreeOf(root, settings, lane), lane)
|
|
60
|
+
const promptPath = defaultPromptFile(root, lane)
|
|
61
|
+
return {
|
|
62
|
+
name: lane,
|
|
63
|
+
status: laneStatusOf(agents, lane),
|
|
64
|
+
reportText: readLaneReport(reportPath)?.text ?? null,
|
|
65
|
+
reportPath,
|
|
66
|
+
reportMtime: reportMtime(reportPath),
|
|
67
|
+
promptPath,
|
|
68
|
+
promptMtime: reportMtime(promptPath),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The open pull request of each lane's branch, as `gh` lists them now; the watcher's own list is gone with its stream. */
|
|
73
|
+
const readPullRequests = async (
|
|
74
|
+
{ root, settings }: Place,
|
|
75
|
+
lanes: readonly string[],
|
|
76
|
+
): Promise<CoordinatorStateInput['pullRequests']> => {
|
|
77
|
+
const branches = lanes.flatMap((lane) => {
|
|
78
|
+
const branch = branchOf(worktreeOf(root, settings, lane))
|
|
79
|
+
return branch ? [{ lane, branch }] : []
|
|
80
|
+
})
|
|
81
|
+
const github = await readGithub(
|
|
82
|
+
root,
|
|
83
|
+
undefined,
|
|
84
|
+
settings.baseBranch,
|
|
85
|
+
branches.map(({ branch }) => branch),
|
|
86
|
+
)
|
|
87
|
+
return github.ok ? openPullRequestsOf(branches, github.pullRequests) : { error: github.error }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A failing `gh` read is a line of the list, never the end of it: what the other files hold still has to appear. */
|
|
91
|
+
const readPullRequestsSafely = async (
|
|
92
|
+
place: Place,
|
|
93
|
+
lanes: readonly string[],
|
|
94
|
+
): Promise<CoordinatorStateInput['pullRequests']> => {
|
|
95
|
+
try {
|
|
96
|
+
return await readPullRequests(place, lanes)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return { error: errorText(error) }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lanesOf = ({ root, settings }: Place, board: string | null, agents: readonly HerdrAgent[] | null): string[] =>
|
|
103
|
+
discoverLanes({
|
|
104
|
+
worktrees: worktreeNames(join(root, settings.worktreeDirectory)),
|
|
105
|
+
agentNames: (agents ?? []).flatMap((agent) => agent.name ?? []),
|
|
106
|
+
sprintLanes: board === null ? [] : parseSprintLanes(board, settings.planLanguage).map((lane) => lane.name),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/** `jh resume --coordinator`: prints what a cleared coordinator takes up, then the reference on it. Reads only. */
|
|
110
|
+
export const printCoordinatorState = async (boardPath: string | null): Promise<void> => {
|
|
111
|
+
const place = placeOf(boardPath)
|
|
112
|
+
const { root, settings, boardFile } = place
|
|
113
|
+
const now = Date.now()
|
|
114
|
+
const board = readBoard(boardFile)
|
|
115
|
+
const agents = readAgents()
|
|
116
|
+
const lanes = lanesOf(place, board, agents)
|
|
117
|
+
const lines = coordinatorStateLines({
|
|
118
|
+
boardPath: relative(root, boardFile),
|
|
119
|
+
board,
|
|
120
|
+
language: settings.planLanguage,
|
|
121
|
+
lanes: lanes.map((lane) => laneFactsOf(place, agents, lane)),
|
|
122
|
+
herdrAnswered: agents !== null,
|
|
123
|
+
pullRequests: await readPullRequestsSafely(place, lanes),
|
|
124
|
+
approvals: readApprovalRequests(root),
|
|
125
|
+
records: recordWaitingItems(root, now),
|
|
126
|
+
now,
|
|
127
|
+
})
|
|
128
|
+
const hint = installedReferenceHint(root, REFERENCE.coordinatorClear)
|
|
129
|
+
for (const line of [...lines, ...referenceLine(hint)]) console.log(line)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `jh resume --clear-line`: prints the first input of the chat after a clear, for the script's `--send`. */
|
|
133
|
+
export const printClearLine = (boardPath: string | null): void => {
|
|
134
|
+
const { root, settings, boardFile } = placeOf(boardPath)
|
|
135
|
+
console.log(resumeTexts(settings.responseLanguage).clearLine(relative(root, boardFile)))
|
|
136
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import { openWaitingItems, type WaitingItem } from '../plan-format/waiting'
|
|
3
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
4
|
+
import { parseAppointmentTime, readAppointmentTable } from '../watch/appointments'
|
|
5
|
+
import { type ApprovalRequestFile, approvalEvents } from '../watch/approvals'
|
|
6
|
+
import { questionsFingerprint } from '../watch/asking-lanes'
|
|
7
|
+
import { questionCountOf } from '../watch/clear-moment'
|
|
8
|
+
import { type HerdrAgent, NO_AGENT } from '../watch/lane-watch'
|
|
9
|
+
import { latestPullRequestOf, PULL_REQUEST_STATE, type WatchedPullRequest } from '../watch/pull-requests'
|
|
10
|
+
|
|
11
|
+
export type LaneFacts = {
|
|
12
|
+
name: string
|
|
13
|
+
status: string
|
|
14
|
+
reportText: string | null
|
|
15
|
+
reportPath: string | null
|
|
16
|
+
reportMtime: number | null
|
|
17
|
+
promptPath: string
|
|
18
|
+
promptMtime: number | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type LanePullRequest = { lane: string; number: number; checks: string }
|
|
22
|
+
|
|
23
|
+
/** What a coordinator's chat lost with `/clear` and the files and herdr still hold. */
|
|
24
|
+
export type CoordinatorStateInput = {
|
|
25
|
+
boardPath: string
|
|
26
|
+
/** Null when the board cannot be read. */
|
|
27
|
+
board: string | null
|
|
28
|
+
language: PlanLanguage
|
|
29
|
+
lanes: readonly LaneFacts[]
|
|
30
|
+
/** False when herdr did not answer: only the board's lanes are known then. */
|
|
31
|
+
herdrAnswered: boolean
|
|
32
|
+
/** The lanes' open pull requests, or why they could not be read. */
|
|
33
|
+
pullRequests: readonly LanePullRequest[] | { error: string }
|
|
34
|
+
approvals: readonly ApprovalRequestFile[]
|
|
35
|
+
/** The sends and questions of `jh waiting`. */
|
|
36
|
+
records: readonly WaitingItem[]
|
|
37
|
+
now: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const INDENT = ' '
|
|
41
|
+
const STANDING_APPROVAL = /standing approval `?([^\s`]+)`? until (\d{2}\.\d{2}\. \d{2}:\d{2})/g
|
|
42
|
+
/** The status of a lane while herdr does not answer: not knowing is no `no agent`. */
|
|
43
|
+
const STATUS_UNKNOWN = 'unknown'
|
|
44
|
+
|
|
45
|
+
const entry = (...parts: readonly string[]): string => `${INDENT}${terminalSafe(parts.join(' · '))}`
|
|
46
|
+
|
|
47
|
+
/** The status herdr reports for a lane; `agents` is null when herdr did not answer. */
|
|
48
|
+
export const laneStatusOf = (agents: readonly HerdrAgent[] | null, lane: string): string => {
|
|
49
|
+
if (agents === null) return STATUS_UNKNOWN
|
|
50
|
+
return agents.find((agent) => agent.name === lane)?.agent_status ?? NO_AGENT
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The open pull request of each lane's branch, from what `gh` listed. */
|
|
54
|
+
export const openPullRequestsOf = (
|
|
55
|
+
branches: readonly { lane: string; branch: string }[],
|
|
56
|
+
pullRequests: readonly WatchedPullRequest[],
|
|
57
|
+
): LanePullRequest[] =>
|
|
58
|
+
branches.flatMap(({ lane, branch }) => {
|
|
59
|
+
const pullRequest = latestPullRequestOf(pullRequests, branch)
|
|
60
|
+
return pullRequest?.state === PULL_REQUEST_STATE.open
|
|
61
|
+
? [{ lane, number: pullRequest.number, checks: pullRequest.checks }]
|
|
62
|
+
: []
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `<board>:<line>` of the row whose cell is `text`, else of the first line that holds it; the board alone when no line
|
|
67
|
+
* does (a cell written with markup).
|
|
68
|
+
*/
|
|
69
|
+
const placeOf = (boardPath: string, board: string, text: string): string => {
|
|
70
|
+
const lines = board.split('\n')
|
|
71
|
+
const cell = lines.findIndex((line) => line.includes(`| ${text} |`))
|
|
72
|
+
const index = cell === -1 ? lines.findIndex((line) => line.includes(text)) : cell
|
|
73
|
+
return index === -1 ? boardPath : `${boardPath}:${index + 1}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether the order in a lane's prompt file has had no report since: the prompt is younger, or there is no report. */
|
|
77
|
+
export const ordersWithoutReport = (promptMtime: number | null, reportMtime: number | null): boolean =>
|
|
78
|
+
promptMtime !== null && (reportMtime === null || promptMtime > reportMtime)
|
|
79
|
+
|
|
80
|
+
const laneTail = ({ reportMtime, reportPath, promptMtime, promptPath }: LaneFacts): string => {
|
|
81
|
+
if (ordersWithoutReport(promptMtime, reportMtime)) return `order without a report: ${promptPath}`
|
|
82
|
+
return reportPath !== null && reportMtime !== null ? reportPath : 'no report'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const laneHeading = (count: number, herdrAnswered: boolean): string => {
|
|
86
|
+
if (herdrAnswered) return `lanes ${count}`
|
|
87
|
+
return count === 0
|
|
88
|
+
? 'lanes unknown: herdr did not answer'
|
|
89
|
+
: `lanes ${count} · herdr did not answer, only the board's lanes`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const laneLines = ({ lanes, herdrAnswered }: CoordinatorStateInput): string[] => [
|
|
93
|
+
laneHeading(lanes.length, herdrAnswered),
|
|
94
|
+
...lanes.map((lane) => {
|
|
95
|
+
const questions = questionCountOf(questionsFingerprint(lane.reportText))
|
|
96
|
+
return entry(`${lane.name} ${lane.status}`, `${questions} questions`, laneTail(lane))
|
|
97
|
+
}),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
const pullRequestLines = (pullRequests: CoordinatorStateInput['pullRequests']): string[] =>
|
|
101
|
+
'error' in pullRequests
|
|
102
|
+
? [terminalSafe(`pull requests unknown: ${pullRequests.error}`)]
|
|
103
|
+
: [
|
|
104
|
+
`pull requests ${pullRequests.length}`,
|
|
105
|
+
...pullRequests.map(({ lane, number, checks }) => entry(`#${number} ${checks}`, lane)),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
const approvalLines = (approvals: readonly ApprovalRequestFile[], now: number): string[] => {
|
|
109
|
+
const open = approvalEvents([], approvals, now)
|
|
110
|
+
return [
|
|
111
|
+
`approvals ${open.length}`,
|
|
112
|
+
...open.map(({ id, lane, tool, summary }) => entry(`${id} ${lane} ${tool}`, summary)),
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const waitingLines = ({ board, boardPath, language, records }: CoordinatorStateInput): string[] => {
|
|
117
|
+
const rows = board === null ? [] : openWaitingItems(board, language)
|
|
118
|
+
return [
|
|
119
|
+
`waiting on the user ${rows.length + records.length}`,
|
|
120
|
+
...rows.map(({ since, item }) => entry(placeOf(boardPath, board ?? '', item), since, item)),
|
|
121
|
+
...records.map(({ id, since, item }) => entry(id ?? '-', since, item)),
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const appointmentLines = ({ board, boardPath, language, now }: CoordinatorStateInput): string[] => {
|
|
126
|
+
const table =
|
|
127
|
+
board === null ? { appointments: [], unreadable: [] } : readAppointmentTable(board, language, new Date(now))
|
|
128
|
+
const open = [...table.appointments.filter((appointment) => appointment.open), ...table.unreadable]
|
|
129
|
+
if (open.length === 0) return ['appointments 0']
|
|
130
|
+
return [
|
|
131
|
+
`appointments ${open.length} · timers survive /clear: compare CronList, create a missing one, delete one without a row`,
|
|
132
|
+
...open.map(({ whenText, action }) => entry(placeOf(boardPath, board ?? '', action), whenText, action)),
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const standingLines = ({ board, boardPath, now }: CoordinatorStateInput): string[] => {
|
|
137
|
+
const holding = (board ?? '').split('\n').flatMap((line, index) =>
|
|
138
|
+
[...line.matchAll(STANDING_APPROVAL)].flatMap(([, path = '', end = '']) => {
|
|
139
|
+
const endsAt = parseAppointmentTime(end, new Date(now))
|
|
140
|
+
return endsAt !== null && endsAt.getTime() > now ? [entry(`${boardPath}:${index + 1}`, `until ${end}`, path)] : []
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
if (holding.length === 0) return ['standing approvals 0']
|
|
144
|
+
return [
|
|
145
|
+
`standing approvals ${holding.length} · a hint only: a cleared chat holds none, ask again before the next merge`,
|
|
146
|
+
...holding,
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* What a coordinator takes up after `/clear`, from files and herdr alone: one line per post with its count, one
|
|
152
|
+
* indented line per entry with its place. A post the board, herdr or `gh` could not answer says so; everywhere else
|
|
153
|
+
* zero means none, an unreadable approvals or records directory included.
|
|
154
|
+
*/
|
|
155
|
+
export const coordinatorStateLines = (input: CoordinatorStateInput): string[] => [
|
|
156
|
+
terminalSafe(`board ${input.boardPath}${input.board === null ? ' not readable' : ''}`),
|
|
157
|
+
...laneLines(input),
|
|
158
|
+
...pullRequestLines(input.pullRequests),
|
|
159
|
+
...approvalLines(input.approvals, input.now),
|
|
160
|
+
...waitingLines(input),
|
|
161
|
+
...appointmentLines(input),
|
|
162
|
+
...standingLines(input),
|
|
163
|
+
]
|
package/src/resume/texts.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
|
4
4
|
export type ResumeTexts = {
|
|
5
5
|
/** One line, no break: herdr would send a break as an enter and split the command. */
|
|
6
6
|
coordinatorLine: (board: string, interrupted: readonly string[], blocked: readonly string[]) => string
|
|
7
|
+
/** One line, no break: the hand-over script refuses one. The first input of the chat after a clear. */
|
|
8
|
+
clearLine: (board: string) => string
|
|
7
9
|
laneText: string
|
|
8
10
|
}
|
|
9
11
|
|
|
@@ -13,12 +15,16 @@ const TEXTS: Record<PlanLanguage, ResumeTexts> = {
|
|
|
13
15
|
[PLAN_LANGUAGE.de]: {
|
|
14
16
|
coordinatorLine: (board, interrupted, blocked) =>
|
|
15
17
|
`/orchestrate ${board} Neustart nach Absturz: Watcher starten (§ 4), Wecker mit den Terminen abgleichen; fortgesetzt: ${listed(interrupted, 'keine')}; vor dem Absturz blockiert: ${listed(blocked, 'keine')}.`,
|
|
18
|
+
clearLine: (board) =>
|
|
19
|
+
`/orchestrate ${board} Aufgeräumt: zuerst jh resume --coordinator --board ${board}, dann Watcher starten (§ 4).`,
|
|
16
20
|
laneText:
|
|
17
21
|
'Der Rechner ist abgestürzt und herdr wurde neu gestartet. Dein letzter Befehl wurde unterbrochen: prüfe, wie weit er kam, und setze ihn fort.',
|
|
18
22
|
},
|
|
19
23
|
[PLAN_LANGUAGE.en]: {
|
|
20
24
|
coordinatorLine: (board, interrupted, blocked) =>
|
|
21
25
|
`/orchestrate ${board} Restart after a crash: start the watcher (§ 4), match the timers against the appointments; resumed: ${listed(interrupted, 'none')}; blocked before the crash: ${listed(blocked, 'none')}.`,
|
|
26
|
+
clearLine: (board) =>
|
|
27
|
+
`/orchestrate ${board} Cleared: first jh resume --coordinator --board ${board}, then start the watcher (§ 4).`,
|
|
22
28
|
laneText:
|
|
23
29
|
'The computer crashed and herdr was restarted. Your last command was interrupted: check how far it got, and carry on with it.',
|
|
24
30
|
},
|
package/src/sync/render.ts
CHANGED
|
@@ -3,9 +3,11 @@ import { repositoryPlaceholderValues } from '../config/repository-values'
|
|
|
3
3
|
import {
|
|
4
4
|
ADDRESS_FORM,
|
|
5
5
|
APPROVAL_TEXTS,
|
|
6
|
+
CARD_FIELDS,
|
|
6
7
|
LANGUAGE_NAME,
|
|
7
8
|
PLAN_VOCABULARY,
|
|
8
9
|
RECOMMENDED_MARK,
|
|
10
|
+
REPORT_FIELDS,
|
|
9
11
|
SELF_MERGE_LABEL,
|
|
10
12
|
STANDING_APPROVAL_TEXTS,
|
|
11
13
|
} from '../vocabulary'
|
|
@@ -53,6 +55,8 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
|
|
|
53
55
|
...prefixed('guard.standingApproval', STANDING_APPROVAL_TEXTS[config.responseLanguage]),
|
|
54
56
|
...prefixed('approval', APPROVAL_TEXTS[config.responseLanguage]),
|
|
55
57
|
['kit.question.recommended', RECOMMENDED_MARK[config.responseLanguage]],
|
|
58
|
+
...prefixed('card', CARD_FIELDS[config.responseLanguage]),
|
|
59
|
+
...prefixed('report', REPORT_FIELDS[config.responseLanguage]),
|
|
56
60
|
['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
|
|
57
61
|
['kit.merge.openAsDraft', config.merge.openAsDraft ? 'on' : 'off'],
|
|
58
62
|
...prefixed('heading', { ...header, ...sections, ...columns, phase }),
|
|
@@ -66,7 +70,7 @@ export const listPlaceholders = (template: string): string[] =>
|
|
|
66
70
|
/**
|
|
67
71
|
* Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
|
|
68
72
|
* `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word.<laneClosed|openSubagents>}}`,
|
|
69
|
-
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.question.recommended}}`, `{{kit.merge.<withoutChecks|openAsDraft>}}`, `{{kit.heading.<key>}}` from the plan
|
|
73
|
+
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.question.recommended}}`, `{{kit.card.<topic|consequences|ifUndecided|readMore>}}`, `{{kit.report.<goal|phase|result|needsYou|next>}}`,`{{kit.merge.<withoutChecks|openAsDraft>}}`, `{{kit.heading.<key>}}` from the plan
|
|
70
74
|
* vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. A
|
|
71
75
|
* command or lock file the config leaves unset renders as `unsetValueText`, never as a guessed default. Other
|
|
72
76
|
* `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -58,6 +58,30 @@ export const RECOMMENDED_MARK: Record<PlanLanguage, string> = {
|
|
|
58
58
|
[PLAN_LANGUAGE.de]: '(Empfohlen)',
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The four fields of the decision card that stands in every dialog, in the question and the option previews: what it
|
|
63
|
+
* is about, what each option leads to, what happens without a decision, and where to read on. The plain question
|
|
64
|
+
* hook sends a dialog back that lacks one at the start of a line, so they are fixed per language and not a config
|
|
65
|
+
* value.
|
|
66
|
+
*/
|
|
67
|
+
export const CARD_FIELDS: Record<
|
|
68
|
+
PlanLanguage,
|
|
69
|
+
{ topic: string; consequences: string; ifUndecided: string; readMore: string }
|
|
70
|
+
> = {
|
|
71
|
+
[PLAN_LANGUAGE.en]: {
|
|
72
|
+
topic: 'What this is about',
|
|
73
|
+
consequences: 'What each option leads to',
|
|
74
|
+
ifUndecided: 'If you do nothing',
|
|
75
|
+
readMore: 'Details',
|
|
76
|
+
},
|
|
77
|
+
[PLAN_LANGUAGE.de]: {
|
|
78
|
+
topic: 'Worum es geht',
|
|
79
|
+
consequences: 'Folgen je Option',
|
|
80
|
+
ifUndecided: 'Wenn du nichts entscheidest',
|
|
81
|
+
readMore: 'Nachlesen',
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
61
85
|
/**
|
|
62
86
|
* The words a lane prompt opens with, followed by the lane's name. The ask hint hook and the project's lane exception
|
|
63
87
|
* recognise a lane by them, so they are fixed per language and not a config value.
|
|
@@ -173,6 +197,37 @@ export type StatusWord = {
|
|
|
173
197
|
anywhere: readonly string[]
|
|
174
198
|
}
|
|
175
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The five fields of a lane's report, in this order, each opening a line of the report: the goal, the phase (written
|
|
202
|
+
* `<phase> x/y`), the result with its numbers, what the lane needs from the user, the next step. They are fixed per
|
|
203
|
+
* language, like the card, so the report template reads them as `{{kit.report.<key>}}` and the watcher recognises them
|
|
204
|
+
* in either language; the `## Questions` heading stays English in both.
|
|
205
|
+
*/
|
|
206
|
+
export const REPORT_FIELDS: Record<
|
|
207
|
+
PlanLanguage,
|
|
208
|
+
{ goal: string; phase: string; result: string; needsYou: string; next: string }
|
|
209
|
+
> = {
|
|
210
|
+
[PLAN_LANGUAGE.en]: { goal: 'Goal', phase: 'Phase', result: 'Result', needsYou: 'Needs you', next: 'Next' },
|
|
211
|
+
[PLAN_LANGUAGE.de]: {
|
|
212
|
+
goal: 'Ziel',
|
|
213
|
+
phase: 'Phase',
|
|
214
|
+
result: 'Ergebnis',
|
|
215
|
+
needsYou: 'Braucht dich',
|
|
216
|
+
next: 'Nächster Schritt',
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The five lines of a plan's head, in the order a plan writes them. */
|
|
221
|
+
export const HEAD_LINE = {
|
|
222
|
+
changes: 'changes',
|
|
223
|
+
status: 'status',
|
|
224
|
+
decisions: 'decisions',
|
|
225
|
+
tryNow: 'tryNow',
|
|
226
|
+
next: 'next',
|
|
227
|
+
} as const
|
|
228
|
+
|
|
229
|
+
export type HeadLine = (typeof HEAD_LINE)[keyof typeof HEAD_LINE]
|
|
230
|
+
|
|
176
231
|
export type PlanVocabulary = {
|
|
177
232
|
header: { date: string; status: string; scope: string }
|
|
178
233
|
sections: {
|
|
@@ -185,7 +240,10 @@ export type PlanVocabulary = {
|
|
|
185
240
|
goal: string
|
|
186
241
|
history: string
|
|
187
242
|
plans: string
|
|
243
|
+
forYou: string
|
|
188
244
|
}
|
|
245
|
+
/** The five labels of the `forYou` section, fixed per language: `- **<label>:** <text>`. */
|
|
246
|
+
head: Record<HeadLine, string>
|
|
189
247
|
columns: { dependsOn: string; writes: string; serialMarker: string }
|
|
190
248
|
phase: string
|
|
191
249
|
/** Opens the line by which a phase says it has no review gate on purpose: `**Kein Review-Gate:** <reason>`. */
|
|
@@ -226,6 +284,14 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
|
|
|
226
284
|
goal: 'Sprintziel',
|
|
227
285
|
history: 'Verlauf',
|
|
228
286
|
plans: 'Pläne',
|
|
287
|
+
forYou: 'Für dich',
|
|
288
|
+
},
|
|
289
|
+
head: {
|
|
290
|
+
changes: 'Was sich für dich ändert',
|
|
291
|
+
status: 'Stand',
|
|
292
|
+
decisions: 'Deine offenen Entscheidungen',
|
|
293
|
+
tryNow: 'Schon ausprobieren',
|
|
294
|
+
next: 'Nächster Schritt',
|
|
229
295
|
},
|
|
230
296
|
columns: { dependsOn: 'Hängt ab von', writes: 'Schreibt', serialMarker: 'Seriell-Marker' },
|
|
231
297
|
phase: 'Phase',
|
|
@@ -261,6 +327,14 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
|
|
|
261
327
|
goal: 'Sprint goal',
|
|
262
328
|
history: 'History',
|
|
263
329
|
plans: 'Plans',
|
|
330
|
+
forYou: 'For you',
|
|
331
|
+
},
|
|
332
|
+
head: {
|
|
333
|
+
changes: 'What changes for you',
|
|
334
|
+
status: 'Status',
|
|
335
|
+
decisions: 'Your open decisions',
|
|
336
|
+
tryNow: 'Try it now',
|
|
337
|
+
next: 'Next step',
|
|
264
338
|
},
|
|
265
339
|
columns: { dependsOn: 'Depends on', writes: 'Writes', serialMarker: 'Serial marker' },
|
|
266
340
|
phase: 'Phase',
|