jackherd 1.23.3 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/INSTALL.md +16 -0
- package/README.md +23 -22
- package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
- package/dist/config.d.ts +13 -1
- package/dist/config.js +178 -132
- package/dist/plan-format.d.ts +121 -3
- package/dist/plan-format.js +176 -35
- package/docs/plan-contract.md +52 -5
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +2 -2
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
- package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
- package/kit/hooks/hooks/shared/boards.ts +22 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +2 -1
- package/kit/workflow/skills/orchestrate/references/backend.md +30 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +1 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
- package/kit/workflow/skills/orchestrate/references/start-lane.md +2 -2
- package/kit/workflow/skills/plan/SKILL.md +3 -1
- package/kit/workflow/skills/plan/references/template-English.md +2 -0
- package/kit/workflow/skills/plan/references/template-German.md +2 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +49 -0
- package/src/backend/command-line.ts +116 -0
- package/src/backend/command.ts +14 -0
- package/src/backend/control.ts +194 -0
- package/src/backend/handover.ts +28 -0
- package/src/backend/herdr.ts +147 -0
- package/src/backend/lock.ts +84 -0
- package/src/backend/process-cwd.ts +32 -0
- package/src/backend/schema.ts +47 -0
- package/src/backend/state.ts +57 -0
- package/src/backend/step.ts +93 -0
- package/src/backend/system.ts +150 -0
- package/src/backend/watch.ts +75 -0
- package/src/brief/command.ts +1 -1
- package/src/brief/lane-facts.ts +2 -2
- package/src/check/command.ts +2 -1
- package/src/check/plan-structure-check.ts +68 -14
- package/src/check/settings.ts +6 -1
- package/src/check/skill-budget-defaults.ts +6 -3
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +6 -0
- package/src/lane/close-board.ts +4 -4
- package/src/lane/close-command.ts +2 -2
- package/src/lane/dev-command.ts +1 -1
- package/src/lane/dev.ts +2 -2
- package/src/lane/port-command.ts +1 -1
- package/src/plan-contract/command.ts +2 -1
- package/src/plan-contract/contract.ts +30 -9
- package/src/plan-format/bare-codes.ts +4 -4
- package/src/plan-format/dependencies.ts +5 -5
- package/src/plan-format/lanes.ts +3 -4
- package/src/plan-format/markdown.ts +125 -10
- package/src/plan-format/open-status.ts +15 -0
- package/src/plan-format/phases.ts +45 -4
- package/src/plan-format/plan-head.ts +31 -9
- package/src/plan-format/plan-structure.ts +43 -7
- package/src/plan-format/progress.ts +3 -0
- package/src/plan-format/sprint-document.ts +4 -4
- package/src/plan-format/unrecognized-sections.ts +21 -0
- package/src/plan-format/waiting.ts +13 -13
- package/src/plans/board-close.ts +25 -16
- package/src/plans/board-pick.ts +1 -1
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/layout-summary.ts +8 -1
- package/src/plans/layout.ts +14 -5
- package/src/plans/live.ts +1 -1
- package/src/plans/plan-panes.ts +1 -1
- package/src/plans/settings.ts +6 -0
- package/src/plans/snapshot.ts +9 -5
- package/src/plans/sprint-export.ts +1 -1
- package/src/plans/texts.ts +3 -0
- package/src/plans/view.ts +1 -0
- package/src/resume/coordinator-read.ts +2 -2
- package/src/settled/command.ts +1 -1
- package/src/settled/settle.ts +5 -5
- package/src/shared/shell-word.ts +2 -0
- package/src/sync/render.ts +20 -5
- package/src/sync/sync-plan.ts +6 -2
- package/src/sync/sync.ts +3 -2
- package/src/vocabulary/index.ts +192 -4
- package/src/watch/appointments.ts +13 -20
- package/src/watch/command.ts +32 -5
- package/src/watch/lane-progress.ts +1 -1
- package/src/watch/merge-overlap-read.ts +2 -2
- package/src/watch/plan-missing-read.ts +3 -1
- package/src/watch/reference-paths.ts +4 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
1
|
+
import { LINE_KEY, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
2
|
import { changedOpenLaneRows, type OpenLaneRow, phaseIdsOfCell } from './lanes'
|
|
3
|
-
import { escapeRegExp, sectionLines } from './markdown'
|
|
3
|
+
import { escapeRegExp, markedKeyOf, sectionLines } from './markdown'
|
|
4
4
|
import {
|
|
5
|
+
BOLD_OPENING,
|
|
6
|
+
decidesPurpose,
|
|
5
7
|
deferralTarget,
|
|
6
8
|
endsPhase,
|
|
7
9
|
isCheckboxLine,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
type PlanPhase,
|
|
12
14
|
parsePlanPhases,
|
|
13
15
|
phaseHeadingPattern,
|
|
16
|
+
phasePurposeOf,
|
|
14
17
|
} from './phases'
|
|
15
18
|
import { terminalSafe } from './terminal-text'
|
|
16
19
|
|
|
@@ -26,10 +29,21 @@ export type PhaseWithOpenBoxes = PhaseWithoutGate & { openLines: number[] }
|
|
|
26
29
|
/** A `PhaseWithOpenBoxes` whose tasks are all ticked on the base branch. */
|
|
27
30
|
export type MergedPhaseWithOpenBoxes = PhaseWithOpenBoxes
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
/** A `### Phase` heading (1-based line) whose section has tasks and no purpose line directly under the heading. */
|
|
33
|
+
export type PhaseWithoutPurpose = PhaseHeading
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
type PhaseSection = PhaseWithoutGate & { tasks: boolean; gate: boolean; purpose: boolean | null; openLines: number[] }
|
|
36
|
+
|
|
37
|
+
const NO_GATE_WORDS = new RegExp(
|
|
38
|
+
`^\\s*\\*\\*(?:${Object.values(PLAN_VOCABULARY)
|
|
39
|
+
.map(({ noReviewGate }) => escapeRegExp(noReviewGate))
|
|
40
|
+
.join('|')})\\b`,
|
|
41
|
+
'i',
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
/** The line by which a phase says it has no review gate on purpose: a bold opening with the words of any known language, or with the marker. */
|
|
45
|
+
const isNoGateLine = (line: string): boolean =>
|
|
46
|
+
NO_GATE_WORDS.test(line) || (BOLD_OPENING.test(line) && markedKeyOf(line) === LINE_KEY.noReviewGate)
|
|
33
47
|
|
|
34
48
|
const tickCount = (phase: PlanPhase): number => phase.items.filter((item) => item.done).length
|
|
35
49
|
|
|
@@ -39,10 +53,13 @@ const tasksDone = (phase: PlanPhase): boolean => {
|
|
|
39
53
|
return tasks.length > 0 && tasks.every((item) => item.done)
|
|
40
54
|
}
|
|
41
55
|
|
|
56
|
+
const settlePurpose = (section: PhaseSection, line: string, language: PlanLanguage): void => {
|
|
57
|
+
if (decidesPurpose(section.purpose !== null, line)) section.purpose = phasePurposeOf(line, language) !== null
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
/** Every `### Phase` section with whether it has tasks and whether it has a gate or says why it has none. */
|
|
43
61
|
const phaseSections = (markdown: string, language: PlanLanguage): PhaseSection[] => {
|
|
44
62
|
const heading = phaseHeadingPattern(language)
|
|
45
|
-
const noGate = noGatePattern(language)
|
|
46
63
|
const sections: PhaseSection[] = []
|
|
47
64
|
let open: PhaseSection | null = null
|
|
48
65
|
for (const [index, line] of markdown.split('\n').entries()) {
|
|
@@ -54,14 +71,16 @@ const phaseSections = (markdown: string, language: PlanLanguage): PhaseSection[]
|
|
|
54
71
|
title: terminalSafe(match[2]).trim(),
|
|
55
72
|
tasks: false,
|
|
56
73
|
gate: false,
|
|
74
|
+
purpose: null,
|
|
57
75
|
openLines: [],
|
|
58
76
|
}
|
|
59
77
|
sections.push(open)
|
|
60
78
|
} else if (endsPhase(line)) {
|
|
61
79
|
open = null
|
|
62
80
|
} else if (open) {
|
|
81
|
+
settlePurpose(open, line, language)
|
|
63
82
|
open.tasks ||= isCheckboxLine(line)
|
|
64
|
-
open.gate ||= isReviewGateLine(line) ||
|
|
83
|
+
open.gate ||= isReviewGateLine(line) || isNoGateLine(line)
|
|
65
84
|
if (isOpenCheckboxLine(line) && deferralTarget(line, open.id) === null) open.openLines.push(index + 1)
|
|
66
85
|
}
|
|
67
86
|
}
|
|
@@ -87,6 +106,23 @@ export const newPhasesWithoutReviewGate = (
|
|
|
87
106
|
.filter(({ id, tasks, gate }) => tasks && !gate && !previousIds.has(id))
|
|
88
107
|
.map(({ line, id, title }) => ({ line, id, title }))
|
|
89
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The phases a plan gained against `previousIds` that have checkboxes and no purpose line as the first non-empty line
|
|
111
|
+
* under the heading (`phasePurposeOf`). Phases the plan already had are not read, like in `newPhasesWithoutReviewGate`.
|
|
112
|
+
*/
|
|
113
|
+
export const newPhasesWithoutPurpose = (
|
|
114
|
+
markdown: string,
|
|
115
|
+
language: PlanLanguage,
|
|
116
|
+
previousIds: ReadonlySet<string>,
|
|
117
|
+
): PhaseWithoutPurpose[] =>
|
|
118
|
+
phaseSections(markdown, language)
|
|
119
|
+
.filter(({ id, tasks, purpose }) => tasks && !purpose && !previousIds.has(id))
|
|
120
|
+
.map(({ line, id, title }) => ({ line, id, title }))
|
|
121
|
+
|
|
122
|
+
/** Every phase of the plan with tasks and without a purpose line: for a plan read whole, where no phase is older than the change. */
|
|
123
|
+
export const phasesWithoutPurpose = (markdown: string, language: PlanLanguage): PhaseWithoutPurpose[] =>
|
|
124
|
+
newPhasesWithoutPurpose(markdown, language, new Set())
|
|
125
|
+
|
|
90
126
|
/** Whether the document has a lanes table (`## Spuren`): a board, not a plan. */
|
|
91
127
|
export const hasLanesTable = (markdown: string, language: PlanLanguage): boolean =>
|
|
92
128
|
sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes).some((line) => line.startsWith('|'))
|
|
@@ -8,6 +8,8 @@ import { type ChecklistItem, type DeferredItem, type PhaseWorkFields, type PlanP
|
|
|
8
8
|
export type PhaseProgress = {
|
|
9
9
|
id: string
|
|
10
10
|
title: string | null
|
|
11
|
+
/** The phase's purpose sentence; absent when the plan has none or lacks the phase. */
|
|
12
|
+
purpose?: string
|
|
11
13
|
done: number
|
|
12
14
|
total: number
|
|
13
15
|
items: ChecklistItem[]
|
|
@@ -46,6 +48,7 @@ export const phaseProgress = (phases: readonly PlanPhase[], id: string): PhasePr
|
|
|
46
48
|
done: items.filter((item) => item.done).length,
|
|
47
49
|
total: items.length,
|
|
48
50
|
items,
|
|
51
|
+
...(phase?.purpose ? { purpose: phase.purpose } : {}),
|
|
49
52
|
...(phase?.deferred ? { deferred: phase.deferred } : {}),
|
|
50
53
|
...(phase?.work ? phaseWorkFields(phase.work) : {}),
|
|
51
54
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
-
import { allTables, firstTableWithHeader, sectionLines, subsectionLines } from './markdown'
|
|
1
|
+
import { COLUMN_KEY, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
+
import { allTables, columnIndex, firstTableWithHeader, sectionLines, subsectionLines } from './markdown'
|
|
3
3
|
|
|
4
4
|
const PHASE_ID_IN_CELL = /^(\d+[a-z]?)\b/
|
|
5
5
|
|
|
@@ -57,9 +57,9 @@ export const readSprintDocument = (markdown: string, language: PlanLanguage): Sp
|
|
|
57
57
|
* first cell of a row starts with the id (`3 — Sync-Engine`); a plan without the table gives an empty map.
|
|
58
58
|
*/
|
|
59
59
|
export const readPhaseDependencies = (planMarkdown: string, language: PlanLanguage): Record<string, string> => {
|
|
60
|
-
const { sections
|
|
60
|
+
const { sections } = PLAN_VOCABULARY[language]
|
|
61
61
|
const { header, rows } = firstTableWithHeader(subsectionLines(planMarkdown, sections.dependencies))
|
|
62
|
-
const column = header.
|
|
62
|
+
const column = columnIndex(header, COLUMN_KEY.dependsOn, language)
|
|
63
63
|
if (column === -1) return {}
|
|
64
64
|
const entries = rows.flatMap((cells) => {
|
|
65
65
|
const id = PHASE_ID_IN_CELL.exec(cells[0] ?? '')?.[1]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isSectionKey, sectionOfHeading } from '../vocabulary'
|
|
2
|
+
import { markedKeyOf, SECTION_HEADING } from './markdown'
|
|
3
|
+
|
|
4
|
+
const isRecognized = (lines: readonly string[], index: number): boolean => {
|
|
5
|
+
const text = SECTION_HEADING.exec(lines[index])?.[1] ?? ''
|
|
6
|
+
const marked = markedKeyOf(lines[index + 1] ?? '')
|
|
7
|
+
return sectionOfHeading(text) !== null || (marked !== null && isSectionKey(marked))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The 1-based line of the first `##` heading of a board or plan when it has such headings and jh reads none of them,
|
|
12
|
+
* neither by the heading of a known language nor by a marker `<!-- jh: <key> -->` directly under it: a document in a
|
|
13
|
+
* language without vocabulary and without markers, which every reader would take for empty. Null when the document has
|
|
14
|
+
* no `##` heading or reads at least one.
|
|
15
|
+
*/
|
|
16
|
+
export const firstUnrecognizedSectionLine = (markdown: string): number | null => {
|
|
17
|
+
const lines = markdown.split('\n')
|
|
18
|
+
const headings = lines.flatMap((line, index) => (SECTION_HEADING.test(line) ? [index] : []))
|
|
19
|
+
if (headings.length === 0 || headings.some((index) => isRecognized(lines, index))) return null
|
|
20
|
+
return headings[0] + 1
|
|
21
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
2
|
-
import { firstTableWithHeader, sectionLines } from './markdown'
|
|
1
|
+
import { COLUMN_KEY, type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
2
|
+
import { columnIndex, firstTableWithHeader, sectionLines } from './markdown'
|
|
3
|
+
import { isOpenStatus } from './open-status'
|
|
3
4
|
import { terminalSafe } from './terminal-text'
|
|
4
5
|
|
|
5
6
|
/** `file` for a file or an Artifact that was sent, `question` for a dialog that ended unanswered. */
|
|
@@ -35,11 +36,9 @@ const namesOf = (cell: string): string[] =>
|
|
|
35
36
|
.map((name) => name.replaceAll('`', '').trim())
|
|
36
37
|
.filter(Boolean)
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return lower === '' || WAITING_VOCABULARY[language].open.some((word) => lower.startsWith(word))
|
|
42
|
-
}
|
|
39
|
+
const OPEN_WORDS = Object.values(WAITING_VOCABULARY).flatMap(({ open }) => open)
|
|
40
|
+
|
|
41
|
+
const isOpen = (status: string): boolean => isOpenStatus(status, OPEN_WORDS)
|
|
43
42
|
|
|
44
43
|
/**
|
|
45
44
|
* The rows of the board's waiting section in `language` (`## Wartet auf dich`, `## Waiting for you`), its columns
|
|
@@ -47,14 +46,15 @@ const isOpen = (status: string, language: PlanLanguage): boolean => {
|
|
|
47
46
|
* column, a section without a table and a row without an item give nothing.
|
|
48
47
|
*/
|
|
49
48
|
export const readWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] => {
|
|
50
|
-
const { heading
|
|
49
|
+
const { heading } = WAITING_VOCABULARY[language]
|
|
51
50
|
const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
|
|
52
|
-
const
|
|
53
|
-
|
|
51
|
+
const positions = [COLUMN_KEY.since, COLUMN_KEY.item, COLUMN_KEY.status].map((key) =>
|
|
52
|
+
columnIndex(header, key, language),
|
|
53
|
+
)
|
|
54
54
|
if (positions.includes(-1)) return []
|
|
55
55
|
const [since, item, status] = positions
|
|
56
|
-
const originPosition = header.
|
|
57
|
-
const unblocksPosition = header.
|
|
56
|
+
const originPosition = columnIndex(header, COLUMN_KEY.origin, language)
|
|
57
|
+
const unblocksPosition = columnIndex(header, COLUMN_KEY.unblocks, language)
|
|
58
58
|
return rows.flatMap((cells) => {
|
|
59
59
|
const text = oneLine(cells[item] ?? '')
|
|
60
60
|
const origin = oneLine(cells[originPosition] ?? '')
|
|
@@ -65,7 +65,7 @@ export const readWaitingItems = (board: string, language: PlanLanguage): Waiting
|
|
|
65
65
|
{
|
|
66
66
|
since: oneLine(cells[since] ?? ''),
|
|
67
67
|
item: text,
|
|
68
|
-
open: isOpen(cells[status] ?? ''
|
|
68
|
+
open: isOpen(cells[status] ?? ''),
|
|
69
69
|
...(origin === '' ? {} : { origin }),
|
|
70
70
|
...(unblocks.length === 0 ? {} : { unblocks }),
|
|
71
71
|
},
|
package/src/plans/board-close.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { twoDigits } from '../plan-format/clock'
|
|
2
|
-
import { firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
|
|
3
|
-
import {
|
|
2
|
+
import { columnIndex, firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
|
|
3
|
+
import {
|
|
4
|
+
APPOINTMENT_VOCABULARY,
|
|
5
|
+
COLUMN_KEY,
|
|
6
|
+
type ColumnKey,
|
|
7
|
+
PLAN_LANGUAGE,
|
|
8
|
+
type PlanLanguage,
|
|
9
|
+
WAITING_VOCABULARY,
|
|
10
|
+
} from '../vocabulary'
|
|
4
11
|
|
|
5
12
|
type SectionColumns = {
|
|
6
13
|
heading: string
|
|
7
|
-
/** The
|
|
8
|
-
keys: readonly [
|
|
9
|
-
|
|
14
|
+
/** The columns of the two cells that identify a row; the status column is found beside them. */
|
|
15
|
+
keys: readonly [ColumnKey, ColumnKey]
|
|
16
|
+
language: PlanLanguage
|
|
10
17
|
}
|
|
11
18
|
|
|
12
19
|
const DONE_WORD: Record<PlanLanguage, string> = { [PLAN_LANGUAGE.de]: 'erledigt', [PLAN_LANGUAGE.en]: 'done' }
|
|
@@ -17,15 +24,17 @@ export const doneStatus = (language: PlanLanguage, now: Date): string =>
|
|
|
17
24
|
|
|
18
25
|
const squeezed = (text: string): string => text.replace(/\s+/g, ' ').trim()
|
|
19
26
|
|
|
20
|
-
const waitingColumns = (language: PlanLanguage): SectionColumns => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
const waitingColumns = (language: PlanLanguage): SectionColumns => ({
|
|
28
|
+
heading: WAITING_VOCABULARY[language].heading,
|
|
29
|
+
keys: [COLUMN_KEY.since, COLUMN_KEY.item],
|
|
30
|
+
language,
|
|
31
|
+
})
|
|
24
32
|
|
|
25
|
-
const appointmentColumns = (language: PlanLanguage): SectionColumns => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
const appointmentColumns = (language: PlanLanguage): SectionColumns => ({
|
|
34
|
+
heading: APPOINTMENT_VOCABULARY[language].heading,
|
|
35
|
+
keys: [COLUMN_KEY.when, COLUMN_KEY.action],
|
|
36
|
+
language,
|
|
37
|
+
})
|
|
29
38
|
|
|
30
39
|
/** The line with the status cell (the `index`th between the bars) replaced; the other cells keep their text as written. */
|
|
31
40
|
const withStatus = (line: string, index: number, status: string): string => {
|
|
@@ -42,7 +51,7 @@ const withStatus = (line: string, index: number, status: string): string => {
|
|
|
42
51
|
*/
|
|
43
52
|
const closeRow = (
|
|
44
53
|
board: string,
|
|
45
|
-
{ heading, keys:
|
|
54
|
+
{ heading, keys: keyColumns, language }: SectionColumns,
|
|
46
55
|
keys: readonly [string, string],
|
|
47
56
|
status: string,
|
|
48
57
|
): string | null => {
|
|
@@ -55,8 +64,8 @@ const closeRow = (
|
|
|
55
64
|
.slice(1, header.endsWith('|') ? -1 : undefined)
|
|
56
65
|
.split('|')
|
|
57
66
|
.map((cell) => cell.trim())
|
|
58
|
-
const positions =
|
|
59
|
-
const statusAt = names.
|
|
67
|
+
const positions = keyColumns.map((key) => columnIndex(names, key, language))
|
|
68
|
+
const statusAt = columnIndex(names, COLUMN_KEY.status, language)
|
|
60
69
|
if (positions.includes(-1) || statusAt === -1) return null
|
|
61
70
|
const row = firstTableRows(lines).find(({ cells }) =>
|
|
62
71
|
positions.every((position, index) => squeezed(cells[position] ?? '') === squeezed(keys[index])),
|
package/src/plans/board-pick.ts
CHANGED
|
@@ -20,7 +20,7 @@ const changedAtOf = (root: string, settings: PlansSettings, file: string): numbe
|
|
|
20
20
|
|
|
21
21
|
const candidateOf = (root: string, settings: PlansSettings, file: string): BoardCandidate => ({
|
|
22
22
|
path: relative(root, file),
|
|
23
|
-
lanes: parseSprintLanes(readFileSync(file, 'utf8'), settings.
|
|
23
|
+
lanes: parseSprintLanes(readFileSync(file, 'utf8'), settings.boardLanguage).map((lane) => lane.name),
|
|
24
24
|
changedAt: changedAtOf(root, settings, file),
|
|
25
25
|
})
|
|
26
26
|
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
withAgentPanes,
|
|
22
22
|
withForeground,
|
|
23
23
|
} from './plan-panes'
|
|
24
|
-
import type
|
|
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.
|
|
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(
|
|
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
|
|
package/src/plans/layout.ts
CHANGED
|
@@ -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} ${
|
|
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 = (
|
|
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
|
|
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
|
@@ -511,7 +511,7 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
|
|
|
511
511
|
if ('done' in outcome) {
|
|
512
512
|
const { closed, notice } = closeRow(outcome.done, {
|
|
513
513
|
sprintFile: live.source.sprintFile,
|
|
514
|
-
language: live.source.settings.
|
|
514
|
+
language: live.source.settings.boardLanguage,
|
|
515
515
|
now: new Date(),
|
|
516
516
|
closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
|
|
517
517
|
})
|
package/src/plans/plan-panes.ts
CHANGED
|
@@ -151,7 +151,7 @@ export const isIdleShell = ({ agent, foreground }: PaneState): boolean =>
|
|
|
151
151
|
* A restored pane whose shell is still starting: nothing in the foreground yet, or a shell first (its prompt may run
|
|
152
152
|
* `git` beside it). Another program there, an editor or a pager, would take the command as its input.
|
|
153
153
|
*/
|
|
154
|
-
const isStartingShell = ({ agent, foreground }: PaneState): boolean =>
|
|
154
|
+
export const isStartingShell = ({ agent, foreground }: PaneState): boolean =>
|
|
155
155
|
!agent && (foreground.length === 0 || SHELLS.has(programOf(foreground[0])))
|
|
156
156
|
|
|
157
157
|
const hasHerdBeside = (panes: readonly PaneState[], agent: PaneState): boolean =>
|
package/src/plans/settings.ts
CHANGED
|
@@ -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
|
|
@@ -23,6 +25,8 @@ export type PlansSettings = Pick<
|
|
|
23
25
|
basePort: number
|
|
24
26
|
/** The command `jh lane dev` starts a lane's dev server with (`lanes.devCommand`); undefined when none is set. */
|
|
25
27
|
devCommand: string | undefined
|
|
28
|
+
/** The shared backend of the lanes (`lanes.backend`); undefined when none is set. */
|
|
29
|
+
backend: JackherdConfig['lanes']['backend']
|
|
26
30
|
fetchIntervalMinutes: number
|
|
27
31
|
/** Whether the running task of a folded lane shimmers; `NO_COLOR` keeps it still whatever this says. */
|
|
28
32
|
shimmer: boolean
|
|
@@ -63,12 +67,14 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
63
67
|
worktreeDirectory: config.worktreeDirectory,
|
|
64
68
|
baseBranch: config.baseBranch,
|
|
65
69
|
planLanguage: config.planLanguage,
|
|
70
|
+
boardLanguage: config.boardLanguage ?? config.planLanguage,
|
|
66
71
|
responseLanguage: config.responseLanguage,
|
|
67
72
|
skillPrefix: config.skillPrefix,
|
|
68
73
|
adapter: config.terminal.adapter,
|
|
69
74
|
minFreeGb: config.lanes.minFreeGb ?? DEFAULT_MIN_FREE_GB,
|
|
70
75
|
basePort: config.lanes.basePort ?? DEFAULT_BASE_PORT,
|
|
71
76
|
devCommand: config.lanes.devCommand,
|
|
77
|
+
backend: config.lanes.backend,
|
|
72
78
|
fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
|
|
73
79
|
shimmer: config.plans.shimmer,
|
|
74
80
|
linkKinds: config.plans.linkKinds,
|
package/src/plans/snapshot.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
814
|
-
yourTurn: yourTurnOfBoard(liveBoard, settings.
|
|
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.
|
|
40
|
+
language: settings.boardLanguage,
|
|
41
41
|
linkKinds: settings.linkKinds,
|
|
42
42
|
})
|
|
43
43
|
|
package/src/plans/texts.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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),
|
package/src/settled/command.ts
CHANGED
|
@@ -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(),
|
|
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
|
package/src/settled/settle.ts
CHANGED
|
@@ -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;
|
|
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.
|
|
52
|
-
const noted = settledRow(withSendFailure(entry, error, context.
|
|
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.
|
|
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.
|
|
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))
|