jackherd 1.24.0 → 1.26.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 (88) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +22 -22
  4. package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
  5. package/dist/config.d.ts +4 -0
  6. package/dist/config.js +4 -1
  7. package/dist/plan-format.d.ts +121 -3
  8. package/dist/plan-format.js +176 -35
  9. package/docs/plan-contract.md +52 -5
  10. package/herdr-plugin/herdr-plugin.toml +1 -1
  11. package/install.sh +1 -1
  12. package/kit/hooks/hooks/approval-gate/index.ts +2 -2
  13. package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
  14. package/kit/hooks/hooks/open-question/record.ts +81 -1
  15. package/kit/hooks/hooks/open-question/run.ts +17 -3
  16. package/kit/hooks/hooks/question-session-end/index.ts +18 -0
  17. package/kit/hooks/hooks/question-session-end/run.ts +37 -0
  18. package/kit/hooks/hooks/sent-file/record.ts +4 -1
  19. package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
  20. package/kit/hooks/hooks/shared/boards.ts +22 -6
  21. package/kit/hooks/hooks.json +8 -0
  22. package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
  23. package/kit/workflow/skills/orchestrate/references/waiting.md +15 -1
  24. package/kit/workflow/skills/plan/SKILL.md +3 -1
  25. package/kit/workflow/skills/plan/references/template-English.md +2 -0
  26. package/kit/workflow/skills/plan/references/template-German.md +2 -0
  27. package/package.json +1 -1
  28. package/schemas/jackherd.schema.json +7 -0
  29. package/src/brief/command.ts +1 -1
  30. package/src/brief/lane-facts.ts +2 -2
  31. package/src/check/command.ts +2 -1
  32. package/src/check/plan-structure-check.ts +68 -14
  33. package/src/check/settings.ts +6 -1
  34. package/src/check/skill-budget-defaults.ts +9 -4
  35. package/src/config/schema.ts +3 -0
  36. package/src/lane/close-board.ts +4 -4
  37. package/src/lane/close-command.ts +2 -2
  38. package/src/lane/dev-command.ts +1 -1
  39. package/src/lane/port-command.ts +1 -1
  40. package/src/plan-contract/command.ts +2 -1
  41. package/src/plan-contract/contract.ts +30 -9
  42. package/src/plan-format/bare-codes.ts +4 -4
  43. package/src/plan-format/dependencies.ts +5 -5
  44. package/src/plan-format/lanes.ts +3 -4
  45. package/src/plan-format/markdown.ts +125 -10
  46. package/src/plan-format/open-status.ts +15 -0
  47. package/src/plan-format/phases.ts +45 -4
  48. package/src/plan-format/plan-head.ts +31 -9
  49. package/src/plan-format/plan-structure.ts +43 -7
  50. package/src/plan-format/progress.ts +3 -0
  51. package/src/plan-format/sprint-document.ts +4 -4
  52. package/src/plan-format/unrecognized-sections.ts +21 -0
  53. package/src/plan-format/waiting.ts +13 -13
  54. package/src/plans/board-close.ts +25 -16
  55. package/src/plans/board-pick.ts +1 -1
  56. package/src/plans/closable-records.ts +11 -10
  57. package/src/plans/close-reason.ts +19 -0
  58. package/src/plans/ensure-panes.ts +2 -2
  59. package/src/plans/layout-summary.ts +8 -1
  60. package/src/plans/layout.ts +14 -5
  61. package/src/plans/live.ts +3 -2
  62. package/src/plans/question-records.ts +23 -2
  63. package/src/plans/questions-read.ts +2 -2
  64. package/src/plans/sent-files-read.ts +2 -2
  65. package/src/plans/sent-files.ts +12 -4
  66. package/src/plans/settings.ts +3 -0
  67. package/src/plans/snapshot.ts +9 -5
  68. package/src/plans/sprint-export.ts +1 -1
  69. package/src/plans/texts.ts +3 -0
  70. package/src/plans/view.ts +1 -0
  71. package/src/resume/coordinator-read.ts +2 -2
  72. package/src/settled/command.ts +1 -1
  73. package/src/settled/settle.ts +5 -5
  74. package/src/sync/render.ts +20 -5
  75. package/src/sync/sync-plan.ts +6 -2
  76. package/src/sync/sync.ts +3 -2
  77. package/src/vocabulary/index.ts +192 -4
  78. package/src/waiting/command.ts +82 -26
  79. package/src/watch/appointments.ts +13 -20
  80. package/src/watch/command.ts +100 -6
  81. package/src/watch/lane-progress.ts +1 -1
  82. package/src/watch/lane-report.ts +4 -0
  83. package/src/watch/merge-overlap-read.ts +2 -2
  84. package/src/watch/obsolete-proposals.ts +175 -0
  85. package/src/watch/obsolete-records.ts +77 -0
  86. package/src/watch/plan-missing-read.ts +3 -1
  87. package/src/watch/reference-paths.ts +2 -0
  88. package/src/watch/watch-events.ts +20 -0
@@ -1,16 +1,16 @@
1
1
  import { join } from 'node:path'
2
2
  import { readRecords, writeRecordAtomically } from '../shared/private-records'
3
3
 
4
- /** A record that waits on the user until something sets `closedAt`. */
5
- export type ClosableRecord = { id: string; closedAt: number | null }
4
+ /** A record that waits on the user until something sets `closedAt`; `closedReason` says who closed it and why. */
5
+ export type ClosableRecord = { id: string; closedAt: number | null; closedReason?: string | null }
6
6
 
7
7
  export type ClosableRecordLog<T extends ClosableRecord> = {
8
8
  /** Every record of the log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
9
9
  read: (root: string) => T[]
10
- /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
11
- close: (root: string, ids: readonly string[], now: number) => number
12
- /** Marks every open record `closedAt` and returns how many it closed. */
13
- closeAll: (root: string, now: number) => number
10
+ /** Marks the named records `closedAt` with `reason` and returns how many it closed; one closed already, or unknown, counts for nothing. */
11
+ close: (root: string, ids: readonly string[], now: number, reason: string) => number
12
+ /** Marks every open record `closedAt` with `reason` and returns how many it closed. */
13
+ closeAll: (root: string, now: number, reason: string) => number
14
14
  }
15
15
 
16
16
  /** The log of `.jackherd/<directoryName>/`: records the `guard` accepts, ordered by `timeOf`. */
@@ -21,14 +21,15 @@ export const closableRecordLog = <T extends ClosableRecord>(
21
21
  ): ClosableRecordLog<T> => {
22
22
  const directoryOf = (root: string): string => join(root, '.jackherd', directoryName)
23
23
  const read = (root: string): T[] => readRecords(directoryOf(root), guard, timeOf)
24
- const closeWhere = (root: string, isChosen: (record: T) => boolean, now: number): number => {
24
+ const closeWhere = (root: string, isChosen: (record: T) => boolean, now: number, reason: string): number => {
25
25
  const open = read(root).filter((record) => record.closedAt === null && isChosen(record))
26
- for (const record of open) writeRecordAtomically(directoryOf(root), { ...record, closedAt: now })
26
+ for (const record of open)
27
+ writeRecordAtomically(directoryOf(root), { ...record, closedAt: now, closedReason: reason })
27
28
  return open.length
28
29
  }
29
30
  return {
30
31
  read,
31
- close: (root, ids, now) => closeWhere(root, (record) => ids.includes(record.id), now),
32
- closeAll: (root, now) => closeWhere(root, () => true, now),
32
+ close: (root, ids, now, reason) => closeWhere(root, (record) => ids.includes(record.id), now, reason),
33
+ closeAll: (root, now, reason) => closeWhere(root, () => true, now, reason),
33
34
  }
34
35
  }
@@ -0,0 +1,19 @@
1
+ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
2
+
3
+ /** Who or what closes a record: the user (a click), the coordinator, a newer send of the same file, a removed lane. */
4
+ export const CLOSE_REASON = {
5
+ user: 'user',
6
+ coordinator: 'coordinator',
7
+ newerVersion: 'newer version',
8
+ laneRemoved: 'lane removed',
9
+ } as const
10
+
11
+ export type CloseReasonKind = (typeof CLOSE_REASON)[keyof typeof CLOSE_REASON]
12
+
13
+ const MAX_DETAIL = 200
14
+
15
+ /** The reason a record carries: the kind, then a free detail on one line without control characters (`coordinator: decided …`). */
16
+ export const closeReasonOf = (kind: CloseReasonKind, detail = ''): string => {
17
+ const text = ellipsize(terminalSafe(detail.replace(/\s+/g, ' ')).trim(), MAX_DETAIL)
18
+ return text === '' ? kind : `${kind}: ${text}`
19
+ }
@@ -21,7 +21,7 @@ import {
21
21
  withAgentPanes,
22
22
  withForeground,
23
23
  } from './plan-panes'
24
- import type { PlansSettings } from './settings'
24
+ import { type PlansSettings } from './settings'
25
25
 
26
26
  const panesWithForeground = (agentPanes: readonly string[]): { panes: PaneState[]; notes: string[] } =>
27
27
  withForeground(withAgentPanes(paneListOf(runHerdr(['pane', 'list'])), agentPanes), (pane) =>
@@ -30,7 +30,7 @@ const panesWithForeground = (agentPanes: readonly string[]): { panes: PaneState[
30
30
 
31
31
  /** The board's lanes that have a worktree, closed ones left out. */
32
32
  const lanesOf = (root: string, settings: PlansSettings, boardFile: string) =>
33
- parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.planLanguage)
33
+ parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage)
34
34
  .filter((lane) => !lane.closed)
35
35
  .map((lane) => ({ name: lane.name, cwd: join(root, settings.worktreeDirectory, lane.name) }))
36
36
  .filter((lane) => existsSync(lane.cwd))
@@ -170,6 +170,10 @@ const checklistFrames = (
170
170
  return [...folder, ...frames, ...(open ? deferredFrames(active.phase, context.width) : [])]
171
171
  }
172
172
 
173
+ /** The phase's purpose sentence, dim, on one row cut at `width`; no row for a phase without one. */
174
+ const purposeFrame = (purpose: string | undefined, width: number): Frame =>
175
+ purpose ? textFrame([fit(paint.dim(plain(purpose)), width)]) : textFrame([])
176
+
173
177
  /**
174
178
  * A phase the lane works on: its head with the bar, no row of its own; under it the checklist of its tasks in the
175
179
  * plan's order, the running one marked (`checklistFrames`). `laneRuns` is false for a phase that only waits.
@@ -184,7 +188,10 @@ const activeFrame = (context: SummaryContext, active: ActivePhase, laneRuns = tr
184
188
  const running = runningTasks(active, laneRuns)
185
189
  return joinFrames([
186
190
  textFrame([phaseHead(active, current, context, model)]),
187
- indented(joinFrames(checklistFrames(inner, key, active, running)), TASK_INDENT),
191
+ indented(
192
+ joinFrames([purposeFrame(active.phase.purpose, inner.width), ...checklistFrames(inner, key, active, running)]),
193
+ TASK_INDENT,
194
+ ),
188
195
  ])
189
196
  }
190
197
 
@@ -182,6 +182,8 @@ export type OverviewView = {
182
182
  awaiting?: WaitingItem[]
183
183
  /** What is the user's turn, in a block under the title; absent for a herd without a board. */
184
184
  yourTurn?: YourTurnBlock
185
+ /** True when the board has `##` headings and jh reads none of them: an empty "Your turn" then says so instead of that nothing waits. */
186
+ boardUnread?: true
185
187
  /** The sources the watcher runs that are not tickets, in a block of their own under "Your turn". */
186
188
  sourceBlock?: SourceBlock
187
189
  /** Whole days since the board was groomed (`groomedDaysAgo`), beside the clock; null or absent when unknown. */
@@ -1403,9 +1405,9 @@ const turnMarkPaint = (mark: string): string => {
1403
1405
  }
1404
1406
 
1405
1407
  /** "Your turn" without a point: one line that cannot be opened, its name in the column of the other block names. */
1406
- const emptyTurnLines = (): BlockLines => ({
1408
+ const emptyTurnLines = (emptyText: string): BlockLines => ({
1407
1409
  open: false,
1408
- lines: [`${blockBar()} ${TEXTS.yourTurnHeading} ${paint.dim(`${ITEM_SEPARATOR} ${TEXTS.yourTurnEmpty}`)}`],
1410
+ lines: [`${blockBar()} ${TEXTS.yourTurnHeading} ${paint.dim(`${ITEM_SEPARATOR} ${emptyText}`)}`],
1409
1411
  hit: { rows: [], moreKey: null },
1410
1412
  })
1411
1413
 
@@ -1413,9 +1415,14 @@ const emptyTurnLines = (): BlockLines => ({
1413
1415
  * What is the user's turn, on top of the herd: the head `Your turn · <n>` with its balance, folded until the user opens
1414
1416
  * it; open, the now rows and the planned rows (`turnRows`), told apart by their mark alone.
1415
1417
  */
1416
- const yourTurnLines = ({ turn, now }: YourTurnBlock, state: Selection, width: number): BlockLines => {
1418
+ const yourTurnLines = (
1419
+ { turn, now }: YourTurnBlock,
1420
+ state: Selection,
1421
+ width: number,
1422
+ emptyText: string,
1423
+ ): BlockLines => {
1417
1424
  const count = turn.now.length + turn.planned.length
1418
- if (count === 0) return emptyTurnLines()
1425
+ if (count === 0) return emptyTurnLines(emptyText)
1419
1426
  const opened = { allNow: state.expanded.has(YOUR_TURN_MORE_KEY), allPlanned: state.expanded.has(YOUR_TURN_LATER_KEY) }
1420
1427
  const clock = new Date(now)
1421
1428
  const block: TopBlock = {
@@ -1702,6 +1709,7 @@ export const renderScreen = (
1702
1709
  watchesTickets,
1703
1710
  awaiting = [],
1704
1711
  yourTurn,
1712
+ boardUnread,
1705
1713
  sourceBlock,
1706
1714
  groomedDays,
1707
1715
  unplaced = [],
@@ -1719,7 +1727,8 @@ export const renderScreen = (
1719
1727
  const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
1720
1728
  const status = [notice ? paint.yellow(notice) : '', groomedCell(groomedDays)].filter(Boolean).join(COLUMN_SEPARATOR)
1721
1729
  const titleLine = spread(`${HEADER_MARK} ${paint.bold(title)}`, status, width)
1722
- const turn = yourTurn ? yourTurnLines(yourTurn, state, width) : null
1730
+ const emptyTurnText = boardUnread ? TEXTS.boardUnread : TEXTS.yourTurnEmpty
1731
+ const turn = yourTurn ? yourTurnLines(yourTurn, state, width, emptyTurnText) : null
1723
1732
  const nowLines = unplaced.map((task) => nowLine(task, width, taskStyle))
1724
1733
  const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
1725
1734
  const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
package/src/plans/live.ts CHANGED
@@ -10,6 +10,7 @@ import { closeWaiting } from '../waiting/command'
10
10
  import { isWatcherRunning } from '../watch/watcher-lock'
11
11
  import { ASKING_NOTICE, type AskDependencies, askCoordinator } from './ask-coordinator'
12
12
  import type { AskTarget } from './ask-target'
13
+ import { CLOSE_REASON, closeReasonOf } from './close-reason'
13
14
  import { errorText } from './error-text'
14
15
  import { focusedScreen } from './focus-overlay'
15
16
  import { noPullRequests } from './github'
@@ -511,9 +512,9 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
511
512
  if ('done' in outcome) {
512
513
  const { closed, notice } = closeRow(outcome.done, {
513
514
  sprintFile: live.source.sprintFile,
514
- language: live.source.settings.planLanguage,
515
+ language: live.source.settings.boardLanguage,
515
516
  now: new Date(),
516
- closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
517
+ closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now(), closeReasonOf(CLOSE_REASON.user)),
517
518
  })
518
519
  if (closed) live.closing = [...live.closing, outcome.done]
519
520
  live.notice = notice
@@ -12,8 +12,23 @@ export type QuestionRecord = {
12
12
  cwd: string
13
13
  session: string
14
14
  closedAt: number | null
15
+ /** Who closed the record and why (`closeReasonOf`); absent or null on a record that predates the field or is open. */
16
+ closedReason?: string | null
17
+ /** The later dialog that probably answered this question in other words; null or absent when none did. */
18
+ supersededBy?: SupersededBy | null
19
+ /** When the session that asked this question ended with it still open (the `SessionEnd` hook); null or absent while it runs. */
20
+ sessionEndedAt?: number | null
15
21
  }
16
22
 
23
+ /** A later dialog's question that answered this one, and when. */
24
+ export type SupersededBy = { question: string; answeredAt: number }
25
+
26
+ const isSupersededBy = (value: unknown): value is SupersededBy =>
27
+ typeof value === 'object' &&
28
+ value !== null &&
29
+ typeof (value as SupersededBy).question === 'string' &&
30
+ typeof (value as SupersededBy).answeredAt === 'number'
31
+
17
32
  /** Whether `value` has the shape of a question record's id: twelve lower-case hex digits. */
18
33
  export const isQuestionId = isRecordId
19
34
 
@@ -28,13 +43,19 @@ export const isQuestionRecord = (value: unknown): value is QuestionRecord => {
28
43
  typeof record.askedAt === 'number' &&
29
44
  typeof record.cwd === 'string' &&
30
45
  typeof record.session === 'string' &&
31
- (record.closedAt === null || typeof record.closedAt === 'number')
46
+ (record.closedAt === null || typeof record.closedAt === 'number') &&
47
+ (record.closedReason == null || typeof record.closedReason === 'string') &&
48
+ (record.supersededBy == null || isSupersededBy(record.supersededBy)) &&
49
+ (record.sessionEndedAt == null || typeof record.sessionEndedAt === 'number')
32
50
  )
33
51
  }
34
52
 
53
+ /** What a row of "Your turn" shows of a question, safe for the terminal. */
54
+ export const questionTextOf = ({ question }: QuestionRecord): string => terminalSafe(question)
55
+
35
56
  const itemOf = (record: QuestionRecord): WaitingItem => ({
36
57
  since: clockText(record.askedAt),
37
- item: terminalSafe(record.question),
58
+ item: questionTextOf(record),
38
59
  kind: WAITING_KIND.question,
39
60
  id: record.id,
40
61
  open: true,
@@ -6,8 +6,8 @@ const log = closableRecordLog('questions', isQuestionRecord, ({ askedAt }) => as
6
6
  /** Every record of the question log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
7
7
  export const readQuestionRecords = log.read
8
8
 
9
- /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
9
+ /** Marks the named records `closedAt` with a reason and returns how many it closed; one closed already, or unknown, counts for nothing. */
10
10
  export const closeQuestionRecords = log.close
11
11
 
12
- /** Marks every open record `closedAt` and returns how many it closed. */
12
+ /** Marks every open record `closedAt` with a reason and returns how many it closed. */
13
13
  export const closeAllQuestionRecords = log.closeAll
@@ -6,8 +6,8 @@ const log = closableRecordLog('sent', isSentFileRecord, ({ sentAt }) => sentAt)
6
6
  /** Every record of the sent-file log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
7
7
  export const readSentRecords = log.read
8
8
 
9
- /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
9
+ /** Marks the named records `closedAt` with a reason and returns how many it closed; one closed already, or unknown, counts for nothing. */
10
10
  export const closeSentRecords = log.close
11
11
 
12
- /** Marks every open record `closedAt` and returns how many it closed. */
12
+ /** Marks every open record `closedAt` with a reason and returns how many it closed. */
13
13
  export const closeAllSentRecords = log.closeAll
@@ -16,6 +16,8 @@ export type SentFileRecord = {
16
16
  session: string
17
17
  agentId: string | null
18
18
  closedAt: number | null
19
+ /** Who closed the record and why (`closeReasonOf`); absent or null on a record that predates the field or is open. */
20
+ closedReason?: string | null
19
21
  }
20
22
 
21
23
  /** A badge for a sent file: a `file:` address and the file's name. */
@@ -55,7 +57,8 @@ export const isSentFileRecord = (value: unknown): value is SentFileRecord => {
55
57
  typeof record.cwd === 'string' &&
56
58
  typeof record.session === 'string' &&
57
59
  (record.agentId === null || typeof record.agentId === 'string') &&
58
- (record.closedAt === null || typeof record.closedAt === 'number')
60
+ (record.closedAt === null || typeof record.closedAt === 'number') &&
61
+ (record.closedReason == null || typeof record.closedReason === 'string')
59
62
  )
60
63
  }
61
64
 
@@ -69,7 +72,7 @@ export const sentLaneOf = ({ files, cwd }: SentFileRecord, worktreeDirectory: st
69
72
  .find((lane) => lane !== null) ?? laneDirectoryOf(cwd, worktreeDirectory)
70
73
 
71
74
  /** The records without an earlier send of the same first file: an artifact updated each round is one send, the newest. */
72
- const latestPerFile = (records: readonly SentFileRecord[]): SentFileRecord[] =>
75
+ export const latestPerFile = (records: readonly SentFileRecord[]): SentFileRecord[] =>
73
76
  records.filter((record, index) => !records.slice(index + 1).some((later) => later.files[0] === record.files[0]))
74
77
 
75
78
  /**
@@ -94,12 +97,17 @@ export const sentChipsByLane = (
94
97
  return lanes
95
98
  }
96
99
 
100
+ /** What a row of "Your turn" shows of a send: the first file's label and how many more follow. */
101
+ export const sentTextOf = (record: SentFileRecord): string => {
102
+ const more = record.files.length > 1 ? ` +${record.files.length - 1}` : ''
103
+ return `${labelOf(record.files[0], record.caption)}${more}`
104
+ }
105
+
97
106
  const itemOf = (record: SentFileRecord): WaitingItem => {
98
107
  const [first] = record.files
99
- const more = record.files.length > 1 ? ` +${record.files.length - 1}` : ''
100
108
  return {
101
109
  since: clockText(record.sentAt),
102
- item: `${labelOf(first, record.caption)}${more}`,
110
+ item: sentTextOf(record),
103
111
  kind: WAITING_KIND.file,
104
112
  id: record.id,
105
113
  open: true,
@@ -16,6 +16,8 @@ export type PlansSettings = Pick<
16
16
  JackherdConfig,
17
17
  'planDirectory' | 'board' | 'worktreeDirectory' | 'baseBranch' | 'planLanguage' | 'responseLanguage' | 'skillPrefix'
18
18
  > & {
19
+ /** The language jh writes the board in: the config's `boardLanguage`, or its `planLanguage` when it sets none. */
20
+ boardLanguage: JackherdConfig['planLanguage']
19
21
  adapter: JackherdConfig['terminal']['adapter']
20
22
  /** Free gigabytes a new lane needs on the disk (`lanes.minFreeGb`). */
21
23
  minFreeGb: number
@@ -65,6 +67,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
65
67
  worktreeDirectory: config.worktreeDirectory,
66
68
  baseBranch: config.baseBranch,
67
69
  planLanguage: config.planLanguage,
70
+ boardLanguage: config.boardLanguage ?? config.planLanguage,
68
71
  responseLanguage: config.responseLanguage,
69
72
  skillPrefix: config.skillPrefix,
70
73
  adapter: config.terminal.adapter,
@@ -15,6 +15,7 @@ import {
15
15
  shortTitle,
16
16
  sprintTitleOf,
17
17
  } from '../plan-format'
18
+ import { firstUnrecognizedSectionLine } from '../plan-format/unrecognized-sections'
18
19
  import type { WaitingItem } from '../plan-format/waiting'
19
20
  import { dropUnused } from '../shared/stat-cache'
20
21
  import type { TaskSignal } from '../task-signals/types'
@@ -140,6 +141,8 @@ export type Snapshot = {
140
141
  groomedDays?: number | null
141
142
  /** What is the user's turn: `waiting`, the board's open appointments and its next section, one row per topic. */
142
143
  yourTurn?: YourTurnBlock
144
+ /** True when the board has `##` headings and jh reads none of them (`firstUnrecognizedSectionLine`). */
145
+ boardUnread?: true
143
146
  /** The sources of `watch.sources` not marked `ticket`, from the watcher's state, for the block under "Your turn". */
144
147
  sourceBlock?: SourceBlock
145
148
  /** The tone of each role, from the config, which `renderSnapshot` sets before it draws. */
@@ -535,7 +538,7 @@ const readBoardLanes = async (
535
538
  const atBase = textAtBase(base, boardPath)
536
539
  const markdown = atBase ?? (await readFallback(boardPath, sprintFile, fallbacks)) ?? ''
537
540
  const liveMarkdown = atBase !== null && existsSync(sprintFile) ? await Bun.file(sprintFile).text() : markdown
538
- return { markdown, liveMarkdown, lanes: rowsOf(markdown, liveMarkdown, settings.planLanguage).lanes }
541
+ return { markdown, liveMarkdown, lanes: rowsOf(markdown, liveMarkdown, settings.boardLanguage).lanes }
539
542
  }
540
543
 
541
544
  /** The plans the sprint's plans tables name, read at the base ref, by link; lanes read theirs themselves. */
@@ -547,7 +550,7 @@ const readListedPlans = async (
547
550
  fallbacks: Fallbacks,
548
551
  ): Promise<Record<string, string>> => {
549
552
  const hrefs = new Set(
550
- readSprintDocument(markdown, source.settings.planLanguage).planTables.flatMap(({ rows }) =>
553
+ readSprintDocument(markdown, source.settings.boardLanguage).planTables.flatMap(({ rows }) =>
551
554
  rows.flatMap((row) => (row.href ? [row.href] : [])),
552
555
  ),
553
556
  )
@@ -757,7 +760,7 @@ export const loadSnapshot = async (
757
760
  await gate.pass()
758
761
  const waitingRecords = readWaitingRecords(root)
759
762
  const waiting = withSourcePrefix(
760
- waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
763
+ waitingItemsOf(liveBoard, settings.boardLanguage, waitingRecords, now),
761
764
  sourceSettings.sourceLabels,
762
765
  )
763
766
  const sentChips = sentChipsByLane(waitingRecords.sent, worktrees, now)
@@ -810,8 +813,9 @@ export const loadSnapshot = async (
810
813
  errors: [...laneSet.errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
811
814
  watch: reading?.health,
812
815
  waiting,
813
- groomedDays: groomedDaysAgo(liveBoard, settings.planLanguage, new Date(now)),
814
- yourTurn: yourTurnOfBoard(liveBoard, settings.planLanguage, waiting, now),
816
+ groomedDays: groomedDaysAgo(liveBoard, settings.boardLanguage, new Date(now)),
817
+ yourTurn: yourTurnOfBoard(liveBoard, settings.boardLanguage, waiting, now),
818
+ ...(firstUnrecognizedSectionLine(liveBoard) === null ? {} : { boardUnread: true as const }),
815
819
  ...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources, sourceSettings), now } } : {}),
816
820
  tones: settings.tones,
817
821
  ...(coordinator ? { coordinator: coordinator.view } : {}),
@@ -37,7 +37,7 @@ const pageOf = ({ root, sprintFile, settings }: Source, snapshot: Snapshot, envi
37
37
  baseRef: baseRefInfo(root, baseRefOf(settings)),
38
38
  repoBlobBase: remoteBlobBase(root, settings.baseBranch),
39
39
  sprintDirectory: dirname(boardRepoPathOf(root, settings, sprintFile)),
40
- language: settings.planLanguage,
40
+ language: settings.boardLanguage,
41
41
  linkKinds: settings.linkKinds,
42
42
  })
43
43
 
@@ -84,6 +84,8 @@ export type PlanTexts = {
84
84
  hintDonePhases: string
85
85
  /** After the head of "Your turn" when nothing waits for the user and nothing is planned. */
86
86
  yourTurnEmpty: string
87
+ /** After the head of "Your turn" when the board has `##` headings and jh reads none of them, so nothing can be said to wait. */
88
+ boardUnread: string
87
89
  notInPlan: string
88
90
  /** Before a task an agent reports that no task of the plan names. */
89
91
  now: string
@@ -189,6 +191,7 @@ export const TEXTS: PlanTexts = {
189
191
  hintNextPhases: 'the next phases',
190
192
  hintDonePhases: 'the done phases',
191
193
  yourTurnEmpty: 'nothing waits for you',
194
+ boardUnread: 'board: no section recognised',
192
195
  notInPlan: 'not in the plan',
193
196
  now: 'now:',
194
197
  subagent: 'Subagent:',
package/src/plans/view.ts CHANGED
@@ -47,6 +47,7 @@ const overviewOf = (snapshot: Snapshot, shimmer: boolean): OverviewView => {
47
47
  awaiting: snapshot.waiting,
48
48
  groomedDays: snapshot.groomedDays,
49
49
  yourTurn: snapshot.yourTurn,
50
+ boardUnread: snapshot.boardUnread,
50
51
  sourceBlock: snapshot.sourceBlock,
51
52
  unplaced: snapshot.unplaced,
52
53
  coordinator: snapshot.coordinator,
@@ -103,7 +103,7 @@ const lanesOf = ({ root, settings }: Place, board: string | null, agents: readon
103
103
  discoverLanes({
104
104
  worktrees: worktreeNames(join(root, settings.worktreeDirectory)),
105
105
  agentNames: (agents ?? []).flatMap((agent) => agent.name ?? []),
106
- sprintLanes: board === null ? [] : parseSprintLanes(board, settings.planLanguage).map((lane) => lane.name),
106
+ sprintLanes: board === null ? [] : parseSprintLanes(board, settings.boardLanguage).map((lane) => lane.name),
107
107
  })
108
108
 
109
109
  /** `jh resume --coordinator`: prints what a cleared coordinator takes up, then the reference on it. Reads only. */
@@ -117,7 +117,7 @@ export const printCoordinatorState = async (boardPath: string | null): Promise<v
117
117
  const lines = coordinatorStateLines({
118
118
  boardPath: relative(root, boardFile),
119
119
  board,
120
- language: settings.planLanguage,
120
+ language: settings.boardLanguage,
121
121
  lanes: lanes.map((lane) => laneFactsOf(place, agents, lane)),
122
122
  herdrAnswered: agents !== null,
123
123
  pullRequests: await readPullRequestsSafely(place, lanes),
@@ -54,7 +54,7 @@ export const run = async (args: string[]): Promise<number> => {
54
54
  foundLanesOf(root, settings, () => terminal.listAgents().map((agent) => agent.name)),
55
55
  )
56
56
  if (board.notice) console.error(board.notice)
57
- const context = { now: new Date(), planLanguage: settings.planLanguage }
57
+ const context = { now: new Date(), boardLanguage: settings.boardLanguage }
58
58
  const outcome = settle(request, context, portsFor(checkout, board.file, settings.adapter))
59
59
  printOutcome(outcome)
60
60
  return outcome.exitCode
@@ -15,7 +15,7 @@ export type SettlePorts = {
15
15
  }
16
16
 
17
17
  /** When it runs and in which language the board is written; the lane is told in English. */
18
- export type SettleContext = { now: Date; planLanguage: PlanLanguage }
18
+ export type SettleContext = { now: Date; boardLanguage: PlanLanguage }
19
19
 
20
20
  export const SETTLE_EXIT = { settled: 0, refused: 1, notSent: 2 } as const
21
21
 
@@ -48,10 +48,10 @@ const chatLine = (request: CheckedRequest): string =>
48
48
  * not be written, or null; the send has failed either way, so a board error must not turn exit 2 into exit 1.
49
49
  */
50
50
  const noteFailure = (entry: SettledEntry, error: string, context: SettleContext, ports: SettlePorts): string | null => {
51
- const row = settledRow(entry, context.planLanguage)
52
- const noted = settledRow(withSendFailure(entry, error, context.planLanguage), context.planLanguage)
51
+ const row = settledRow(entry, context.boardLanguage)
52
+ const noted = settledRow(withSendFailure(entry, error, context.boardLanguage), context.boardLanguage)
53
53
  try {
54
- ports.writeBoard(withReplacedRow(ports.readBoard(), row, noted, context.planLanguage))
54
+ ports.writeBoard(withReplacedRow(ports.readBoard(), row, noted, context.boardLanguage))
55
55
  return null
56
56
  } catch (noteError) {
57
57
  return errorText(noteError)
@@ -80,7 +80,7 @@ export const settle = (request: SettledRequest, context: SettleContext, ports: S
80
80
  }
81
81
  const { checked } = result
82
82
  const entry = entryOf(checked, context.now, ports.evidenceVersion(checked.evidence.path))
83
- ports.writeBoard(withSettledRow(ports.readBoard(), settledRow(entry, context.planLanguage), context.planLanguage))
83
+ ports.writeBoard(withSettledRow(ports.readBoard(), settledRow(entry, context.boardLanguage), context.boardLanguage))
84
84
  const error = ports.send(checked.lane, laneMessageOf(checked))
85
85
  if (error === null) return { exitCode: SETTLE_EXIT.settled, stdout: [chatLine(checked)], stderr: [] }
86
86
  return notSent(checked, error, noteFailure(entry, error, context, ports))
@@ -5,7 +5,9 @@ import {
5
5
  APPROVAL_TEXTS,
6
6
  CARD_FIELDS,
7
7
  LANGUAGE_NAME,
8
+ PLAN_LANGUAGE,
8
9
  PLAN_VOCABULARY,
10
+ type PlanLanguage,
9
11
  RECOMMENDED_MARK,
10
12
  REPORT_FIELDS,
11
13
  SELF_MERGE_LABEL,
@@ -35,9 +37,20 @@ const checkFreeList = (patterns: readonly string[]): string =>
35
37
  const prefixed = (prefix: string, values: Record<string, string>): [string, string][] =>
36
38
  Object.entries(values).map(([key, value]) => [`kit.${prefix}.${key}`, value])
37
39
 
38
- /** Every placeholder a kit file may use, with its value for this project. */
39
- const placeholderValues = (config: JackherdConfig): Map<string, string> => {
40
- const { header, sections, columns, phase, laneClosed, openSubagents } = PLAN_VOCABULARY[config.planLanguage]
40
+ const TEMPLATE_FILE = /(?:^|\/)template-([A-Za-z]+)\.md$/
41
+
42
+ /**
43
+ * The language a plan template file is written in, from its name (`template-English.md` is English); undefined for any
44
+ * other path. Such a file renders with the vocabulary of that language, never with the project's plan language.
45
+ */
46
+ export const templateLanguageOf = (path: string): PlanLanguage | undefined => {
47
+ const name = TEMPLATE_FILE.exec(path)?.[1]
48
+ return Object.values(PLAN_LANGUAGE).find((language) => LANGUAGE_NAME[language] === name)
49
+ }
50
+
51
+ /** Every placeholder a kit file may use, with its value for this project; the plan words follow `vocabularyLanguage`. */
52
+ const placeholderValues = (config: JackherdConfig, vocabularyLanguage: PlanLanguage): Map<string, string> => {
53
+ const { header, sections, columns, phase, laneClosed, openSubagents } = PLAN_VOCABULARY[vocabularyLanguage]
41
54
  return new Map([
42
55
  ['kit.responseLanguage', LANGUAGE_NAME[config.responseLanguage]],
43
56
  ['kit.responseAddress', ADDRESS_FORM[config.responseLanguage][config.responseAddress]],
@@ -74,15 +87,17 @@ export const listPlaceholders = (template: string): string[] =>
74
87
  * vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. A
75
88
  * command or lock file the config leaves unset renders as `unsetValueText`, never as a guessed default. Other
76
89
  * `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
77
- * file unrendered. `overrides` win over the config: a global kit renders what each repository decides as
90
+ * file unrendered. `templateLanguage` (`templateLanguageOf` of the file) fills the plan words from that language's
91
+ * vocabulary instead of the project's plan language. `overrides` win over the config: a global kit renders what each repository decides as
78
92
  * `<name>` (`REPOSITORY_TOKENS`).
79
93
  */
80
94
  export const renderTemplate = (
81
95
  template: string,
82
96
  config: JackherdConfig,
83
97
  overrides: ReadonlyMap<string, string> = new Map(),
98
+ templateLanguage?: PlanLanguage,
84
99
  ): string => {
85
- const values = placeholderValues(config)
100
+ const values = placeholderValues(config, templateLanguage ?? config.planLanguage)
86
101
  return template.replace(PLACEHOLDER, (_match, name: string) => {
87
102
  const value = overrides.get(name) ?? values.get(name)
88
103
  if (value === undefined) throw new Error(`Unknown kit placeholder {{${name}}}`)
@@ -77,13 +77,17 @@ export const assertOutsideWorktrees = (paths: Iterable<string>, config: Jackherd
77
77
  }
78
78
  }
79
79
 
80
- export const renderKitFiles = (kit: KitSource, render: (template: string) => string): Map<string, RenderedFile> => {
80
+ /** `render` gets the kit path too, so a `template-<Language>.md` can be filled in its own language. */
81
+ export const renderKitFiles = (
82
+ kit: KitSource,
83
+ render: (template: string, path: string) => string,
84
+ ): Map<string, RenderedFile> => {
81
85
  const rendered = new Map<string, RenderedFile>()
82
86
  for (const file of kit.files) {
83
87
  const parsed = parseKitPath(file.path)
84
88
  if (!parsed.ok) throw new Error(`Kit file ${file.path} ${parsed.reason}`)
85
89
  if (rendered.has(parsed.path)) throw new Error(`Kit file ${parsed.path} appears twice`)
86
- const content = render(file.template)
90
+ const content = render(file.template, parsed.path)
87
91
  const mode = file.executable ? FILE_MODE.executable : FILE_MODE.regular
88
92
  rendered.set(parsed.path, { content, checksum: checksum(content), mode })
89
93
  }
package/src/sync/sync.ts CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  removeFileSafely,
30
30
  writeFileSafely,
31
31
  } from './project-fs'
32
- import { renderTemplate } from './render'
32
+ import { renderTemplate, templateLanguageOf } from './render'
33
33
  import { parseSettings, type Settings } from './settings-hooks'
34
34
  import {
35
35
  type AcceptRequest,
@@ -184,7 +184,8 @@ const buildPlan = async ({
184
184
  }: PlanInput): Promise<SyncPlan> => {
185
185
  const { manifest, text: manifestText } = await readManifest(toolDirectory)
186
186
  const accept = parseAcceptPaths(acceptPaths)
187
- const render = (template: string) => renderTemplate(template, config, overrides)
187
+ const render = (template: string, path: string) =>
188
+ renderTemplate(template, config, overrides, templateLanguageOf(path))
188
189
  const rendered = renderKitFiles(kit, render)
189
190
  assertOutsideWorktrees([...rendered.keys(), ...Object.keys(manifest.files), ...accept.files], config)
190
191
  const files = await planFiles(locate, rendered, manifest, accept)