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
@@ -13,8 +13,20 @@ export type QuestionRecord = {
13
13
  cwd: string
14
14
  session: string
15
15
  closedAt: number | null
16
+ /** Who closed the record and why; absent or null on a record that predates the field or is open. */
17
+ closedReason?: string | null
18
+ /** A later dialog's question that probably answered this one in other words; null or absent when none did. */
19
+ supersededBy?: SupersededBy | null
20
+ /** When the session that asked this question ended with it still open (the `SessionEnd` hook); null or absent while it runs. */
21
+ sessionEndedAt?: number | null
16
22
  }
17
23
 
24
+ /** A later dialog's question that answered a record's question, and when. */
25
+ export type SupersededBy = AnsweredQuestion
26
+
27
+ const isSupersededBy = (value: unknown): value is SupersededBy =>
28
+ isRecord(value) && typeof value.question === 'string' && typeof value.answeredAt === 'number'
29
+
18
30
  export type QuestionContext = {
19
31
  cwd: string
20
32
  session: string
@@ -76,6 +88,26 @@ const resultsOf = (entries: readonly JsonObject[]): Map<string, JsonObject | nul
76
88
  return results
77
89
  }
78
90
 
91
+ /** How the client words the result of a dialog that a `PreToolUse` hook sent back: the user never saw it. */
92
+ const HOOK_REFUSAL = new RegExp(`^PreToolUse:${ASK_USER_QUESTION_TOOL} hook error`)
93
+
94
+ const resultTextOf = (block: JsonObject): string => {
95
+ if (typeof block.content === 'string') return block.content
96
+ if (!Array.isArray(block.content)) return ''
97
+ return block.content.map((part) => (isRecord(part) && typeof part.text === 'string' ? part.text : '')).join('\n')
98
+ }
99
+
100
+ /** The ids of the dialog calls a hook refused: the user was never asked, so they leave nothing open. */
101
+ const refusedByHookOf = (entries: readonly JsonObject[]): Set<string> =>
102
+ new Set(
103
+ entries.flatMap((entry) =>
104
+ contentOf(entry)
105
+ .filter((block) => block.type === CONTENT_BLOCK_TYPE.toolResult && block.is_error === true)
106
+ .filter((block) => HOOK_REFUSAL.test(resultTextOf(block)))
107
+ .flatMap((block) => (typeof block.tool_use_id === 'string' ? [block.tool_use_id] : [])),
108
+ ),
109
+ )
110
+
79
111
  /** Whether the transcript went on after the call, other than with the rest of the message that made it. */
80
112
  const wentOnAfter = (ask: Ask, entries: readonly JsonObject[]): boolean =>
81
113
  entries
@@ -102,6 +134,49 @@ export const answeredSince = (
102
134
  at: number,
103
135
  ): AnsweredQuestion | undefined => answered.find((done) => done.question === question && done.answeredAt > at)
104
136
 
137
+ /** The reason a record carries when a later dialog answered its question; the package's `closeReasonOf` spells it the same (contract test). */
138
+ export const ANSWERED_REASON = 'user: answered in a dialog'
139
+
140
+ /** Fewest words the shorter of two questions has to carry before one can be taken for a rewording of the other. */
141
+ const MIN_REWORDED_WORDS = 3
142
+ /** Share of the longer question's words the shorter one has to keep, with no word of its own besides. */
143
+ const MIN_REWORDED_SHARE = 0.7
144
+
145
+ /** Most words the longer question may add: one more word can be a restriction (`lane A`), two or more are another question. */
146
+ const MAX_EXTRA_WORDS = 1
147
+ /** A word that turns a question around: adding one is no rewording (`run it` against `do not run it`). */
148
+ const NEGATIONS: ReadonlySet<string> = new Set(['nicht', 'kein', 'keine', 'keinen', 'nie', 'not', 'no', 'never'])
149
+
150
+ const wordsOf = (question: string): Set<string> =>
151
+ new Set(question.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word !== ''))
152
+
153
+ /**
154
+ * Whether two different questions read as one asked twice: all the words of the shorter one are in the longer one,
155
+ * and they are most of it. A word that only one side has and the other lacks (`A` against `B`, `export` against
156
+ * `import`) makes them two questions.
157
+ */
158
+ export const isRewording = (left: string, right: string): boolean => {
159
+ const [short, long] = [wordsOf(left), wordsOf(right)].sort((first, second) => first.size - second.size)
160
+ if (short.size < MIN_REWORDED_WORDS || short.size / long.size < MIN_REWORDED_SHARE) return false
161
+ if (![...short].every((word) => long.has(word))) return false
162
+ const extra = [...long].filter((word) => !short.has(word))
163
+ return extra.length <= MAX_EXTRA_WORDS && !extra.some((word) => NEGATIONS.has(word))
164
+ }
165
+
166
+ /**
167
+ * The record with `supersededBy` set to the first dialog after it that answered a different question that reads as the
168
+ * same one reworded; the record itself when none did or one is set already. Only a suspicion for the coordinator to
169
+ * weigh: it never closes the record.
170
+ */
171
+ export const withSupersededBy = (record: QuestionRecord, answered: readonly AnsweredQuestion[]): QuestionRecord => {
172
+ if (record.closedAt !== null || record.supersededBy) return record
173
+ const later = answered.find(
174
+ ({ question, answeredAt }) =>
175
+ answeredAt > record.askedAt && question !== record.question && isRewording(question, record.question),
176
+ )
177
+ return later ? { ...record, supersededBy: { question: later.question, answeredAt: later.answeredAt } } : record
178
+ }
179
+
105
180
  /**
106
181
  * The questions of the main thread that stand open: their dialog was rejected or interrupted, or answered with "later",
107
182
  * and no dialog answered the same text since. A call that carries its own answers asked nobody; a sidechain is no dialog
@@ -110,7 +185,9 @@ export const answeredSince = (
110
185
  export const openQuestionsOf = (entries: readonly unknown[], context: QuestionContext): QuestionRecord[] => {
111
186
  const thread = mainThread(entries)
112
187
  const results = resultsOf(thread)
188
+ const refused = refusedByHookOf(thread)
113
189
  return asksOf(thread)
190
+ .filter((ask) => !refused.has(ask.toolUseId))
114
191
  .filter((ask) => endedUnanswered(ask, results, thread))
115
192
  .map((ask) => ({ ask, question: questionTextOf(ask.question) }))
116
193
  .filter(({ ask, question }) => question !== '' && !answeredSince(context.answered, question, ask.askedAt))
@@ -133,4 +210,7 @@ export const isQuestionRecord = (value: unknown): value is QuestionRecord =>
133
210
  typeof value.askedAt === 'number' &&
134
211
  typeof value.cwd === 'string' &&
135
212
  typeof value.session === 'string' &&
136
- (value.closedAt === null || typeof value.closedAt === 'number')
213
+ (value.closedAt === null || typeof value.closedAt === 'number') &&
214
+ (value.closedReason == null || typeof value.closedReason === 'string') &&
215
+ (value.supersededBy == null || isSupersededBy(value.supersededBy)) &&
216
+ (value.sessionEndedAt == null || typeof value.sessionEndedAt === 'number')
@@ -1,6 +1,13 @@
1
1
  import { ASK_USER_QUESTION_TOOL, parseTranscript } from '../shared/dialog-answers'
2
2
  import { isRecord } from '../shared/json'
3
- import { answeredQuestionsOf, answeredSince, openQuestionsOf, type QuestionRecord } from './record'
3
+ import {
4
+ ANSWERED_REASON,
5
+ answeredQuestionsOf,
6
+ answeredSince,
7
+ openQuestionsOf,
8
+ type QuestionRecord,
9
+ withSupersededBy,
10
+ } from './record'
4
11
 
5
12
  /** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
6
13
  export type OpenQuestionDependencies = {
@@ -42,14 +49,21 @@ export const runOpenQuestion = (input: unknown, dependencies: OpenQuestionDepend
42
49
  const answered = answeredQuestionsOf(entries)
43
50
  for (const record of stored.filter(({ closedAt }) => closedAt === null)) {
44
51
  const answer = answeredSince(answered, record.question, record.askedAt)
45
- if (answer) dependencies.writeRecord(main, { ...record, closedAt: answer.answeredAt })
52
+ if (answer) {
53
+ dependencies.writeRecord(main, { ...record, closedAt: answer.answeredAt, closedReason: ANSWERED_REASON })
54
+ continue
55
+ }
56
+ if (record.session !== session) continue
57
+ const marked = withSupersededBy(record, answered)
58
+ if (marked !== record) dependencies.writeRecord(main, marked)
59
+ else if (record.sessionEndedAt != null) dependencies.writeRecord(main, { ...record, sessionEndedAt: null })
46
60
  }
47
61
  const known = new Set(stored.map(({ id }) => id))
48
62
  const openTexts = new Set(stored.filter(({ closedAt }) => closedAt === null).map(({ question }) => question))
49
63
  for (const record of openQuestionsOf(entries, { cwd, session, answered, idOf: dependencies.idOf })) {
50
64
  if (known.has(record.id) || openTexts.has(record.question)) continue
51
65
  openTexts.add(record.question)
52
- dependencies.writeRecord(main, record)
66
+ dependencies.writeRecord(main, withSupersededBy(record, answered))
53
67
  }
54
68
  } catch {
55
69
  // A failed record is no record; the herd shows no question.
@@ -0,0 +1,18 @@
1
+ // SessionEnd hook of Claude Code, shipped by jackherd: marks the still open question records that the ending session
2
+ // asked (`sessionEndedAt` in `.jackherd/questions/` of the main checkout), also on `/clear`, so the watcher can propose
3
+ // them as `session ended`. It marks only its own session's records, prints nothing, never blocks and always exits 0.
4
+ import { mainCheckoutOf } from '../shared/checkout'
5
+ import { readQuestionRecords, writeQuestionRecord } from '../open-question/store'
6
+ import { runQuestionSessionEnd } from './run'
7
+
8
+ try {
9
+ runQuestionSessionEnd(JSON.parse(await Bun.stdin.text()), {
10
+ mainCheckoutOf,
11
+ readRecords: readQuestionRecords,
12
+ writeRecord: writeQuestionRecord,
13
+ now: Date.now,
14
+ })
15
+ } catch {
16
+ // Unreadable input marks nothing.
17
+ }
18
+ process.exit(0)
@@ -0,0 +1,37 @@
1
+ import { isRecord } from '../shared/json'
2
+ import type { QuestionRecord } from '../open-question/record'
3
+
4
+ /** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
5
+ export type SessionEndDependencies = {
6
+ mainCheckoutOf: (cwd: string) => string
7
+ readRecords: (main: string) => QuestionRecord[]
8
+ writeRecord: (main: string, record: QuestionRecord) => void
9
+ now: () => number
10
+ }
11
+
12
+ /**
13
+ * The open question records that `session` asked and that carry no end mark yet, marked `sessionEndedAt`. Only the
14
+ * records of the ending session: another session in the same directory is not touched.
15
+ */
16
+ export const endedRecords = (records: readonly QuestionRecord[], session: string, now: number): QuestionRecord[] =>
17
+ records
18
+ .filter((record) => record.session === session && record.closedAt === null && record.sessionEndedAt == null)
19
+ .map((record) => ({ ...record, sessionEndedAt: now }))
20
+
21
+ /**
22
+ * Marks the ending session's still open questions. Claude Code's `SessionEnd` hook input: `session_id`, `cwd`, `reason`
23
+ * (`clear`, `logout`, `prompt_input_exit`, `resume`, `other`); every reason ends the session's own questions, the mark
24
+ * is only a suspicion for the watcher to propose. Writes nothing on any failure.
25
+ */
26
+ export const runQuestionSessionEnd = (input: unknown, dependencies: SessionEndDependencies): void => {
27
+ try {
28
+ if (!isRecord(input)) return
29
+ const { cwd, session_id: session } = input
30
+ if (typeof cwd !== 'string' || typeof session !== 'string') return
31
+ const main = dependencies.mainCheckoutOf(cwd)
32
+ for (const record of endedRecords(dependencies.readRecords(main), session, dependencies.now()))
33
+ dependencies.writeRecord(main, record)
34
+ } catch {
35
+ // A failed mark is no mark; the question stays open as before.
36
+ }
37
+ }
@@ -15,6 +15,8 @@ export type SentRecord = {
15
15
  session: string
16
16
  agentId: string | null
17
17
  closedAt: number | null
18
+ /** Who closed the record and why; absent or null on a record that predates the field or is open. */
19
+ closedReason?: string | null
18
20
  }
19
21
 
20
22
  export type SentCall = {
@@ -125,4 +127,5 @@ export const isSentRecord = (value: unknown): value is SentRecord =>
125
127
  typeof value.cwd === 'string' &&
126
128
  typeof value.session === 'string' &&
127
129
  (value.agentId === null || typeof value.agentId === 'string') &&
128
- (value.closedAt === null || typeof value.closedAt === 'number')
130
+ (value.closedAt === null || typeof value.closedAt === 'number') &&
131
+ (value.closedReason == null || typeof value.closedReason === 'string')
@@ -27,16 +27,38 @@ const PLAN_COLUMN = 2
27
27
 
28
28
  const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
29
29
 
30
- const sectionLines = (markdown: string, heading: string): string[] => {
30
+ const MARKER = /<!--\s*jh:\s*([a-z-]+)\s*-->/
31
+
32
+ const markerOf = (text: string | undefined): string | undefined => (text === undefined ? undefined : MARKER.exec(text)?.[1])
33
+
34
+ const languagesFrom = (language: BoardLanguage): BoardLanguage[] =>
35
+ [language, ...(Object.keys(BOARD_WORDS) as BoardLanguage[]).filter((other) => other !== language)]
36
+
37
+ /** The index of the lanes section's heading: the marker `<!-- jh: lanes -->` under it, else the heading of `language`, else of the other language. */
38
+ const lanesHeadingIndex = (lines: string[], language: BoardLanguage): number => {
39
+ const marked = lines.findIndex((line, index) => SECTION_END.test(line) && markerOf(lines[index + 1]) === 'lanes')
40
+ if (marked !== -1) return marked
41
+ for (const candidate of languagesFrom(language)) {
42
+ const pattern = new RegExp(`^##\\s+${escapeRegExp(BOARD_WORDS[candidate].lanes)}\\s*$`)
43
+ const found = lines.findIndex((line) => pattern.test(line))
44
+ if (found !== -1) return found
45
+ }
46
+ return -1
47
+ }
48
+
49
+ const lanesSectionLines = (markdown: string, language: BoardLanguage): string[] => {
31
50
  const lines = markdown.split('\n')
32
- const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`)
33
- const start = lines.findIndex((line) => pattern.test(line))
34
- if (start === -1) return []
35
- const rest = lines.slice(start + 1)
51
+ const heading = lanesHeadingIndex(lines, language)
52
+ if (heading === -1) return []
53
+ const rest = lines.slice(heading + 1)
36
54
  const end = rest.findIndex((line) => SECTION_END.test(line))
37
55
  return end === -1 ? rest : rest.slice(0, end)
38
56
  }
39
57
 
58
+ /** Whether a lane's name cell says the lane was cleaned up: the marker `<!-- jh: lane-closed -->`, or the word of any language. */
59
+ const isClosedCell = (cell: string): boolean =>
60
+ markerOf(cell) === 'lane-closed' || Object.values(BOARD_WORDS).some(({ laneClosed }) => cell.includes(laneClosed))
61
+
40
62
  const cellsOf = (line: string): string[] =>
41
63
  line
42
64
  .slice(1, line.endsWith('|') ? -1 : undefined)
@@ -55,7 +77,7 @@ const firstTableLines = (lines: string[]): string[] => {
55
77
  const laneRows = (markdown: string, language: BoardLanguage): Row[] => {
56
78
  const rows: Row[] = []
57
79
  let previous: string | null = null
58
- for (const line of firstTableLines(sectionLines(markdown, BOARD_WORDS[language].lanes))) {
80
+ for (const line of firstTableLines(lanesSectionLines(markdown, language))) {
59
81
  const cells = cellsOf(line)
60
82
  const first = cells[0] ?? ''
61
83
  const lane: string | null = first.match(LANE_NAME)?.[1] ?? (first.startsWith(CONTINUATION) ? previous : null)
@@ -73,11 +95,11 @@ const planPathOf = (boardDirectory: string, cell: string): string[] => {
73
95
  }
74
96
 
75
97
  /** The rows of one lane since it was last cleaned up: a named row without the mark after a closed one starts it anew. */
76
- const currentRows = <Row extends { cells: string[] }>(rows: Row[], closedWord: string): Row[] =>
98
+ const currentRows = <Row extends { cells: string[] }>(rows: Row[]): Row[] =>
77
99
  rows.reduce<{ closed: boolean; kept: Row[] }>(
78
100
  ({ closed, kept }, row) => {
79
101
  const first = row.cells[0] ?? ''
80
- const marked = first.includes(closedWord)
102
+ const marked = isClosedCell(first)
81
103
  const startsAnew = closed && !marked && !first.startsWith(CONTINUATION)
82
104
  return { closed: marked || (closed && !startsAnew), kept: startsAnew ? [row] : [...kept, row] }
83
105
  },
@@ -100,11 +122,10 @@ export const boardPlansOf = (markdown: string, boardDirectory: string, language:
100
122
 
101
123
  /** Every lane the lanes table names whose current rows do not say it was cleaned up, in table order, read in one pass. */
102
124
  export const openLaneNamesOf = (markdown: string, language: BoardLanguage): string[] => {
103
- const { laneClosed } = BOARD_WORDS[language]
104
125
  const rowsByLane = new Map<string, Row[]>()
105
126
  for (const row of laneRows(markdown, language)) rowsByLane.set(row.lane, [...(rowsByLane.get(row.lane) ?? []), row])
106
127
  return [...rowsByLane].flatMap(([lane, rows]) =>
107
- currentRows(rows, laneClosed).some(({ cells }) => (cells[0] ?? '').includes(laneClosed)) ? [] : [lane],
128
+ currentRows(rows).some(({ cells }) => isClosedCell(cells[0] ?? '')) ? [] : [lane],
108
129
  )
109
130
  }
110
131
 
@@ -117,9 +138,9 @@ export const lanePlansOf = (
117
138
  ): LanePlans | undefined => {
118
139
  const all = laneRows(markdown, language).filter((row) => row.lane === lane)
119
140
  if (all.length === 0) return undefined
120
- const rows = currentRows(all, BOARD_WORDS[language].laneClosed)
141
+ const rows = currentRows(all)
121
142
  return {
122
- closed: rows.some(({ cells }) => (cells[0] ?? '').includes(BOARD_WORDS[language].laneClosed)),
143
+ closed: rows.some(({ cells }) => isClosedCell(cells[0] ?? '')),
123
144
  plans: [...new Set(rows.flatMap(({ cells }) => planPathOf(boardDirectory, cells[PLAN_COLUMN] ?? '')))],
124
145
  }
125
146
  }
@@ -12,9 +12,13 @@ import { isRepositoryDirectory } from './repository-directory'
12
12
  /** A board's directory relative to the repository, and its text. */
13
13
  export type Board = { directory: string; text: string }
14
14
 
15
- /** The plan language, the plan directory and the board pattern and file below it, and where the lane worktrees live. */
15
+ /**
16
+ * The plan language, the language the board is written in (the plan language when the config sets none), the plan
17
+ * directory and the board pattern and file below it, and where the lane worktrees live.
18
+ */
16
19
  export type BoardLocation = {
17
20
  planLanguage: BoardLanguage
21
+ boardLanguage: BoardLanguage
18
22
  planDirectory: string
19
23
  board: { pattern: string; file: string }
20
24
  worktreeDirectory: string
@@ -42,8 +46,16 @@ const isPlanLanguage = (value: unknown): value is BoardLanguage => typeof value
42
46
  * The board location's values of a config, each with its default where absent and `undefined` where present but
43
47
  * invalid; `board` is the config's `board` section.
44
48
  */
45
- export const boardLocationValues = (config: Record<string, unknown>, board: Record<string, unknown>) => ({
46
- planLanguage: valueOr(config, 'planLanguage', isPlanLanguage, 'en'),
49
+ export const boardLocationValues = (config: Record<string, unknown>, board: Record<string, unknown>) => {
50
+ const planLanguage = valueOr(config, 'planLanguage', isPlanLanguage, 'en')
51
+ return {
52
+ planLanguage,
53
+ boardLanguage: valueOr(config, 'boardLanguage', isPlanLanguage, planLanguage ?? 'en'),
54
+ ...directoryValues(config, board),
55
+ }
56
+ }
57
+
58
+ const directoryValues = (config: Record<string, unknown>, board: Record<string, unknown>) => ({
47
59
  planDirectory: valueOr(config, 'planDirectory', isRepositoryDirectory, 'docs/plans'),
48
60
  pattern: valueOr(board, 'pattern', matches(BOARD_PATTERN), 'board-*'),
49
61
  file: valueOr(board, 'file', matches(BOARD_FILE), 'board.md'),
@@ -54,10 +66,14 @@ export const boardLocationValues = (config: Record<string, unknown>, board: Reco
54
66
  export const boardLocationOf = (config: Record<string, unknown>): BoardLocation | null => {
55
67
  const board = config.board ?? {}
56
68
  if (!isRecord(board)) return null
57
- const { planLanguage, planDirectory, pattern, file, worktreeDirectory } = boardLocationValues(config, board)
58
- if (planLanguage === undefined || planDirectory === undefined || worktreeDirectory === undefined) return null
69
+ const { planLanguage, boardLanguage, planDirectory, pattern, file, worktreeDirectory } = boardLocationValues(
70
+ config,
71
+ board,
72
+ )
73
+ if (planLanguage === undefined || boardLanguage === undefined) return null
74
+ if (planDirectory === undefined || worktreeDirectory === undefined) return null
59
75
  if (pattern === undefined || file === undefined) return null
60
- return { planLanguage, planDirectory, board: { pattern, file }, worktreeDirectory }
76
+ return { planLanguage, boardLanguage, planDirectory, board: { pattern, file }, worktreeDirectory }
61
77
  }
62
78
 
63
79
  /** The boards under the plan directory of the main checkout whose directory name matches the board pattern. */
@@ -96,6 +96,14 @@
96
96
  "timeout": 10
97
97
  }
98
98
  },
99
+ "question-session-end": {
100
+ "event": "SessionEnd",
101
+ "handler": {
102
+ "type": "command",
103
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/question-session-end/index.ts",
104
+ "timeout": 5
105
+ }
106
+ },
99
107
  "return-brief": {
100
108
  "event": "UserPromptSubmit",
101
109
  "handler": {
@@ -8,13 +8,13 @@ is missing or comes twice.
8
8
  It reads the plans at `origin/{{kit.baseBranch}}`. From a lane's worktree, once the lane has work
9
9
  there, it takes the phases the lane's rows name and any phase that is new there or ticked further
10
10
  than on the base. It redraws when a plan file changes or a fetch of the base branch lands in the
11
- main checkout (the watcher fetches it as soon as the remote moved). While it runs, it also sets each
12
- lane's sidebar title (`P4 · 7/12`) and clears it on exit.
11
+ main checkout (the watcher fetches it as soon as the remote moved). While it runs it sets each
12
+ lane's sidebar title (`P4 · 7/12`), clearing it on exit. An opened lane adds each phase's purpose line.
13
13
 
14
14
  A phase the lane ticked to the end in its worktree stays its current phase until the base branch
15
15
  carries its ticks (the watcher marks it `abgehakt, nicht gemergt` / `ticked, not merged`; the herd
16
16
  shows the branch's `↑n` instead); an open phase the lane works on goes first. The lane's newest row decides
17
- which phases count: ticks in an older phase (tidying the plan) take over only when that row names no
17
+ which phases count: ticks in an older phase take over only when that row names no
18
18
  phase or only merged ones, which is why each new row starts with its phase number (§ 2 step 5).
19
19
 
20
20
  ## Notices per lane
@@ -1,6 +1,6 @@
1
1
  # § 4 Waiting for you: the rows beyond the basic one
2
2
 
3
- Read when `jh waiting` or the heartbeat's `for you (<n>)` names this file, a lane waits on the user's answer,
3
+ Read when a watcher line `waiting closed` or `waiting obsolete?`, `jh waiting` or the heartbeat's `for you (<n>)` names this file, a lane waits on the user's answer,
4
4
  or something you sent the user needs no row of its own.
5
5
 
6
6
  - **A lane waits on the answer:** add its name as in the lanes table (a phase may follow) to the optional
@@ -14,3 +14,17 @@ or something you sent the user needs no row of its own.
14
14
  the user's. A dialog that ended without an answer (declined, cut off, or answered "later") needs none
15
15
  either: the kit hook `open-question` records it, and it closes once the same question is answered in a
16
16
  dialog.
17
+ - **The watcher closes a send by itself** when a newer send has the same first entry (`newer version: <id>`,
18
+ whoever sent it) or its lane's worktree stayed gone for two heartbeats (`lane removed: <name>`), and reports
19
+ `waiting closed <n>: <id> <reason>; …`. `jh waiting --closed` lists the last 24 hours with reasons.
20
+ - **A proposal `waiting obsolete? <key> <reason> "<text>"`** closes nothing and comes once per reason. Check
21
+ it against the thing itself. A key `row-…` is a board row (find it by the quoted text): set its status cell to
22
+ `done` (`erledigt` in a German board) with `(<you> <DD.MM.> <HH:MM>: <why>)`; the table is yours alone, no
23
+ code writes into it. Any other key is a record id: `jh waiting close <id> --reason "<why>"`. A thing still
24
+ wanted stays open. Reasons: `pr #<n> merged|closed`,
25
+ `lane removed: <name>`, `superseded: <question>` (a later dialog answered it in nearly the same words),
26
+ `session ended`.
27
+ - **A decision the hooks cannot see** (made in the chat or in a lane's report, written into the board by you):
28
+ close the records it settles in the same turn, `jh waiting close <id> --reason "decided: <decision>"`.
29
+ - **A board row's key is a hash of its item text:** a reworded row gets a new key and can raise its proposal
30
+ again, as can an unreadable `.jackherd/sent`; close it then.
@@ -11,6 +11,8 @@ You create a structured concept/plan document in `{{kit.planDirectory}}/`.
11
11
  > written in {{kit.planLanguage}}. Keep the section headings of the template **verbatim** — the plan
12
12
  > parser, `/implement` and `spec-review` find `## {{kit.heading.decisions}}`,
13
13
  > `## {{kit.heading.implementation}}` and `## {{kit.heading.documentation}}` by those exact names.
14
+ > Another language: each heading, table head and purpose line carries `<!-- jh: <key> -->` (keys:
15
+ > `docs/plan-contract.md`, "Language and markers"); done when `jh plan check` shows no `missing`.
14
16
 
15
17
  ## Your Workflow
16
18
 
@@ -36,7 +38,7 @@ The closing documentation section reads `## N — …` with rows `N.1` to `N.5`:
36
38
 
37
39
  A checkbox moved on purpose to a later phase stays where it is, unticked, and ends in the target: `- [ ] Update the documentation → P9`. The herd counts it neither as open nor as done in its own phase and lists it as open in phase 9. The arrow stands only at the end of the line. Once a phase's tasks are ticked on the base branch, every box still open in it, gate boxes included, is ticked or moved on this way; `jh check` checks both.
38
40
 
39
- A task starts with its short name: `- [ ] **Name**: …` (a review-gate item keeps its own form). The name has 40 characters at most and names the subject and the result (`Heartbeat shows progress without ticks`); the how, the conditions, the origin and the evidence follow the colon. A phase's title is its name; its context goes into its origin line, a quote under the heading (`> Origin: …`, in the plan's language). A task the user adds later: `- [ ] **Name**: … (User <DD.MM.> <HH:MM>)`.
41
+ A task starts with its short name: `- [ ] **Name**: …` (a review-gate item keeps its own form). The name has 40 characters at most and names the subject and the result (`Heartbeat shows progress without ticks`); the how, the conditions, the origin and the evidence follow the colon. A phase's title is its name; directly under it stands the purpose line of the template, one sentence saying what is different afterwards and for whom, so that a reader outside the project understands the phase from that line alone; `jh check` asks for it on every new phase. The phase's context goes into its origin line, a quote below the purpose line (`> Origin: …`, in the plan's language). A task the user adds later: `- [ ] **Name**: … (User <DD.MM.> <HH:MM>)`.
40
42
 
41
43
  An Artifact that belongs to the plan (a mockup, a draft for review, a findings page) stands as a Markdown link with its title in the phase it concerns, in the same move as its first publish: `**Mockup:** [Title](url)` or in the origin line. The herd shows them as chips; nothing else ties an Artifact published from the main checkout to the lane.
42
44
 
@@ -50,6 +50,8 @@ What changes concretely? Files, modules, flows.
50
50
 
51
51
  ### {{kit.heading.phase}} 1: ...
52
52
 
53
+ **What it is about:** what is different afterwards, for whom; "nothing visible" when that is the case
54
+
53
55
  - [ ] **Name of step 1**: how, conditions, origin, evidence
54
56
  - [ ] **Name of step 2**: how, conditions, origin, evidence
55
57
 
@@ -50,6 +50,8 @@ Was ändert sich konkret? Dateien, Module, Flows.
50
50
 
51
51
  ### {{kit.heading.phase}} 1: ...
52
52
 
53
+ **Worum es geht:** was danach anders ist, für wen; „nichts Sichtbares“, wenn das so ist
54
+
53
55
  - [ ] **Name von Schritt 1**: Wie, Bedingungen, Herkunft, Beleg
54
56
  - [ ] **Name von Schritt 2**: Wie, Bedingungen, Herkunft, Beleg
55
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -29,6 +29,13 @@
29
29
  "en"
30
30
  ]
31
31
  },
32
+ "boardLanguage": {
33
+ "type": "string",
34
+ "enum": [
35
+ "de",
36
+ "en"
37
+ ]
38
+ },
32
39
  "planDirectory": {
33
40
  "default": "docs/plans",
34
41
  "type": "string",
@@ -35,7 +35,7 @@ export const run = async (args: string[]): Promise<number> => {
35
35
  const here = worktreeRoot(process.cwd())
36
36
  const settings = readSettings([here, root])
37
37
  const picked = boardFor(boardPath, [root, here], settings, () => foundLanesOf(root, settings, () => []))
38
- for (const line of briefLines(laneFactsOf(root, settings, picked.file, Date.now()), settings.planLanguage))
38
+ for (const line of briefLines(laneFactsOf(root, settings, picked.file, Date.now()), settings.boardLanguage))
39
39
  console.log(line)
40
40
  return 0
41
41
  } catch (error) {
@@ -3,7 +3,7 @@ import { dirname, join, relative } from 'node:path'
3
3
  import { parseSprintLanes, resolvePlanPath, type SprintLane } from '../plan-format'
4
4
  import { type QuestionRecord } from '../plans/question-records'
5
5
  import { type SentFileRecord, sentLaneOf } from '../plans/sent-files'
6
- import type { PlansSettings } from '../plans/settings'
6
+ import { type PlansSettings } from '../plans/settings'
7
7
  import { laneDirectoryOf } from '../plans/unlisted-lanes'
8
8
  import { readWaitingRecords } from '../plans/waiting-records'
9
9
  import { isStillWaiting } from '../plans/waiting-window'
@@ -49,7 +49,7 @@ export const laneFactsOf = (root: string, settings: PlansSettings, boardFile: st
49
49
  const sprintDirectory = dirname(relative(root, boardFile))
50
50
  const worktreeDirectory = join(root, settings.worktreeDirectory)
51
51
  const records = readWaitingRecords(root)
52
- return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.planLanguage)
52
+ return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage)
53
53
  .filter(({ closed }) => !closed)
54
54
  .map((lane) => {
55
55
  const href = planHrefOf(lane)
@@ -223,7 +223,7 @@ const openBoxesCheckOf = (root: string, settings: CheckSettings): CheckResult |
223
223
  lane,
224
224
  planDirectory: settings.planDirectory,
225
225
  board: settings.board,
226
- language: settings.planLanguage,
226
+ language: settings.boardLanguage,
227
227
  level: settings.check.openBoxes,
228
228
  baseOf: contentAt(root, baseRefOf(settings)),
229
229
  })
@@ -253,6 +253,7 @@ const ownChecksOf = (
253
253
  paths,
254
254
  planDirectory: settings.planDirectory,
255
255
  language: settings.planLanguage,
256
+ boardLanguage: settings.boardLanguage,
256
257
  previousOf: contentAt(root, 'HEAD'),
257
258
  baseOf: contentAt(root, baseRefOf(settings)),
258
259
  planRoots: planRootsOf(root, settings.worktreeDirectory),