jackherd 1.18.2 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +30 -25
  4. package/assets/icons/model.svg +1 -0
  5. package/dist/{chunk-N5FAOQZL.js → chunk-JDO6O3UO.js} +52 -2
  6. package/dist/config.d.ts +16 -2
  7. package/dist/config.js +25 -4
  8. package/dist/plan-format.d.ts +84 -1
  9. package/dist/plan-format.js +85 -1
  10. package/docs/plan-contract.md +8 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/hooks/approval-gate/decision.ts +10 -11
  14. package/kit/hooks/hooks/approval-gate/run.ts +23 -3
  15. package/kit/hooks/hooks/browser-guard/decision.ts +7 -0
  16. package/kit/hooks/hooks/browser-guard/index.ts +50 -0
  17. package/kit/hooks/hooks/plain-question/arguments.ts +14 -1
  18. package/kit/hooks/hooks/plain-question/card.ts +102 -0
  19. package/kit/hooks/hooks/plain-question/form.ts +25 -9
  20. package/kit/hooks/hooks/plain-question/index.ts +20 -25
  21. package/kit/hooks/hooks/plain-question/recommendation.ts +6 -6
  22. package/kit/hooks/hooks/return-brief/decision.ts +29 -0
  23. package/kit/hooks/hooks/return-brief/index.ts +37 -0
  24. package/kit/hooks/hooks/return-brief/settings.ts +11 -0
  25. package/kit/hooks/hooks/shared/coordinator-call.ts +25 -0
  26. package/kit/hooks/hooks/shared/transcript.ts +9 -0
  27. package/kit/hooks/hooks.json +18 -1
  28. package/kit/review/agents/security-audit.md +1 -1
  29. package/kit/workflow/agents/browser-check.md +29 -0
  30. package/kit/workflow/skills/implement/SKILL.md +5 -3
  31. package/kit/workflow/skills/orchestrate/SKILL.md +36 -14
  32. package/kit/workflow/skills/orchestrate/references/acceptance.md +10 -9
  33. package/kit/workflow/skills/orchestrate/references/coordinator-clear.md +45 -0
  34. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +15 -12
  35. package/kit/workflow/skills/orchestrate/references/start-lane.md +3 -3
  36. package/kit/workflow/skills/plan/SKILL.md +2 -0
  37. package/kit/workflow/skills/plan/references/template-English.md +8 -0
  38. package/kit/workflow/skills/plan/references/template-German.md +8 -0
  39. package/package.json +1 -1
  40. package/schemas/jackherd.schema.json +21 -0
  41. package/src/brief/command.ts +45 -0
  42. package/src/brief/lane-brief.ts +51 -0
  43. package/src/brief/lane-facts.ts +64 -0
  44. package/src/check/skill-budget-defaults.ts +13 -2
  45. package/src/cli.ts +1 -0
  46. package/src/config/schema.ts +24 -0
  47. package/src/lane/command.ts +3 -1
  48. package/src/lane/port-command.ts +37 -0
  49. package/src/lane/port.ts +56 -0
  50. package/src/lane-prompt/texts.ts +2 -2
  51. package/src/plan-contract/command.ts +1 -0
  52. package/src/plan-contract/contract.ts +28 -5
  53. package/src/plan-format/index.ts +1 -0
  54. package/src/plan-format/plan-head.ts +131 -0
  55. package/src/plans/active-tasks.ts +8 -1
  56. package/src/plans/click-hint.ts +2 -1
  57. package/src/plans/focus-overlay.ts +5 -2
  58. package/src/plans/folded-detail.ts +47 -0
  59. package/src/plans/lane-line.ts +86 -0
  60. package/src/plans/lane-reason.ts +24 -4
  61. package/src/plans/layout-phase.ts +4 -20
  62. package/src/plans/layout-summary.ts +56 -45
  63. package/src/plans/layout.ts +178 -44
  64. package/src/plans/port-listening.ts +25 -0
  65. package/src/plans/selection.ts +11 -0
  66. package/src/plans/settings.ts +4 -0
  67. package/src/plans/snapshot.ts +27 -9
  68. package/src/plans/task-checklist.ts +57 -0
  69. package/src/plans/texts.ts +34 -9
  70. package/src/resume/command.ts +40 -6
  71. package/src/resume/coordinator-read.ts +136 -0
  72. package/src/resume/state.ts +163 -0
  73. package/src/resume/texts.ts +6 -0
  74. package/src/sync/render.ts +5 -1
  75. package/src/vocabulary/index.ts +74 -0
  76. package/src/watch/clear-moment.ts +228 -0
  77. package/src/watch/command.ts +92 -8
  78. package/src/watch/lane-report.ts +5 -1
  79. package/src/watch/reference-paths.ts +8 -4
  80. package/src/watch/watch-events.ts +13 -0
@@ -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
- lanesWaitOnYou: (lanes: number) => string
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, their text being rows of "Your turn". */
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 row under the tasks a phase shows of its open ones: how many more are open and how many are done. */
57
- moreTasks: (open: number, done: number) => string
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
- lanesWaitOnYou: (lanes) => `${lanes} ${lanes === 1 ? 'waits' : 'wait'} on you`,
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
- moreTasks: (open, done) =>
157
- [...(open > 0 ? [`${open} more open`] : []), ...(done > 0 ? [`${done} done`] : [])].join(' · '),
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: 'waits on you', meaning: '· n questions: open questions of its report' },
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: '◌ dim', meaning: 'running (guessed)' },
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' },
@@ -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
- if (args.length > 0) {
208
- console.log('Usage: jh resume (run by the herdr plugin when herdr starts; takes no arguments)')
209
- return args.includes('--help') || args.includes('-h') ? 0 : 1
234
+ const request = parseResumeArgs(args)
235
+ if (request.kind === 'usage') {
236
+ console.log(RESUME_USAGE)
237
+ return request.asked ? 0 : 1
210
238
  }
211
- const { systemResumePorts } = await import('./ports')
212
- return resumeAll(systemResumePorts())
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
+ ]
@@ -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
  },
@@ -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
@@ -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',