jackherd 1.24.0 → 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 +5 -0
- package/README.md +21 -21
- package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -1
- 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/references/herd.md +3 -3
- 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 +7 -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/config/schema.ts +3 -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/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/settings.ts +3 -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/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 +5 -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
|
@@ -27,16 +27,38 @@ const PLAN_COLUMN = 2
|
|
|
27
27
|
|
|
28
28
|
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
29
29
|
|
|
30
|
-
const
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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[]
|
|
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
|
|
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
|
|
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
|
|
141
|
+
const rows = currentRows(all)
|
|
121
142
|
return {
|
|
122
|
-
closed: rows.some(({ cells }) => (cells[0] ?? '')
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
58
|
-
|
|
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. */
|
|
@@ -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
|
|
12
|
-
lane's sidebar title (`P4 · 7/12`)
|
|
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
|
|
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
|
|
@@ -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;
|
|
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
package/src/brief/command.ts
CHANGED
|
@@ -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.
|
|
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) {
|
package/src/brief/lane-facts.ts
CHANGED
|
@@ -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
|
|
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.
|
|
52
|
+
return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage)
|
|
53
53
|
.filter(({ closed }) => !closed)
|
|
54
54
|
.map((lane) => {
|
|
55
55
|
const href = planHrefOf(lane)
|
package/src/check/command.ts
CHANGED
|
@@ -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.
|
|
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),
|
|
@@ -13,12 +13,14 @@ import {
|
|
|
13
13
|
laneProgress,
|
|
14
14
|
longPhaseTitles,
|
|
15
15
|
mergedPhasesWithOpenBoxes,
|
|
16
|
+
newPhasesWithoutPurpose,
|
|
16
17
|
newPhasesWithoutReviewGate,
|
|
17
18
|
openBoxesOfPhase,
|
|
18
19
|
PHASE_CELL_LIMIT,
|
|
19
20
|
PHASE_TITLE_LIMIT,
|
|
20
21
|
type PhaseWithOpenBoxes,
|
|
21
22
|
type PhaseWithoutGate,
|
|
23
|
+
type PhaseWithoutPurpose,
|
|
22
24
|
type PhaseWithoutRow,
|
|
23
25
|
PLAN_VOCABULARY,
|
|
24
26
|
type PlanLanguage,
|
|
@@ -38,7 +40,10 @@ import {
|
|
|
38
40
|
shortNameFindings,
|
|
39
41
|
} from '../plan-format'
|
|
40
42
|
import { boardRowsWithBareCodes, type RowWithBareCode } from '../plan-format/bare-codes'
|
|
43
|
+
import { namesState } from '../plan-format/markdown'
|
|
44
|
+
import { firstUnrecognizedSectionLine } from '../plan-format/unrecognized-sections'
|
|
41
45
|
import { mergePlanPhases } from '../plans/phase-source'
|
|
46
|
+
import { CELL_KEY, PARTIAL_PHASE_WORDS, SECTION_KEY } from '../vocabulary'
|
|
42
47
|
import { changedPlansOf } from './deferral-arrows'
|
|
43
48
|
import { CHECK_STATUS, type CheckResult } from './result'
|
|
44
49
|
import { OPEN_BOXES_LEVEL, type OpenBoxesLevel } from './schema'
|
|
@@ -55,6 +60,8 @@ export type PlanCheckInput = {
|
|
|
55
60
|
paths: readonly string[]
|
|
56
61
|
planDirectory: string
|
|
57
62
|
language: PlanLanguage
|
|
63
|
+
/** The language the boards are read in first; `language` when absent. */
|
|
64
|
+
boardLanguage?: PlanLanguage
|
|
58
65
|
previousOf: ContentAt
|
|
59
66
|
baseOf: ContentAt
|
|
60
67
|
/** Where a board's plans are read, `root` when absent: the checkout, the main checkout and the lane worktrees. */
|
|
@@ -124,6 +131,16 @@ const gateReport = (language: PlanLanguage): Report<PhaseWithoutGate> => ({
|
|
|
124
131
|
clean: 'every new phase has a review gate',
|
|
125
132
|
})
|
|
126
133
|
|
|
134
|
+
const purposeReport = (language: PlanLanguage): Report<PhaseWithoutPurpose> => ({
|
|
135
|
+
name: 'Plan phase purposes',
|
|
136
|
+
noun: 'plan',
|
|
137
|
+
rule: 'new phases say what they are about',
|
|
138
|
+
unmet: CHECK_STATUS.fail,
|
|
139
|
+
text: ({ path, line, id, title }) =>
|
|
140
|
+
`${path}:${line} Phase ${id} ${title} has tasks but no **${PLAN_VOCABULARY[language].phasePurpose}:** line; say in one sentence what is different afterwards, for whom, directly under the heading`,
|
|
141
|
+
clean: 'every new phase says what it is about',
|
|
142
|
+
})
|
|
143
|
+
|
|
127
144
|
const rowReport: Report<RowWithoutPhase> = {
|
|
128
145
|
name: 'Board rows without a phase',
|
|
129
146
|
noun: 'board',
|
|
@@ -179,6 +196,21 @@ const unlistedReport: Report<PhaseWithoutRow> = {
|
|
|
179
196
|
clean: 'every phase has a row in the dependency table',
|
|
180
197
|
}
|
|
181
198
|
|
|
199
|
+
const sectionReport: Report<{ line: number }> = {
|
|
200
|
+
name: 'Plan sections',
|
|
201
|
+
noun: 'plan',
|
|
202
|
+
rule: 'jh reads at least one section',
|
|
203
|
+
unmet: CHECK_STATUS.warn,
|
|
204
|
+
text: ({ path, line }) =>
|
|
205
|
+
`${path}:${line} none of the ## headings is a section jh reads, so every reader finds the document empty; write one in a known language or put <!-- jh: <key> --> on the line directly under it (keys: ${Object.values(SECTION_KEY).join(', ')})`,
|
|
206
|
+
clean: 'jh reads a section of every changed plan and board',
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const sectionsOf = ({ path, current }: Changed): Found<{ line: number }>[] => {
|
|
210
|
+
const line = firstUnrecognizedSectionLine(current)
|
|
211
|
+
return line === null ? [] : [{ path, line }]
|
|
212
|
+
}
|
|
213
|
+
|
|
182
214
|
const shortNameText = (found: Found<ShortNameFinding>): string =>
|
|
183
215
|
found.problem === SHORT_NAME_PROBLEM.long
|
|
184
216
|
? `${found.path}:${found.line} Phase ${found.phase}: the short name has ${found.length} characters (limit ${SHORT_NAME_LIMIT})`
|
|
@@ -207,10 +239,15 @@ const withPath =
|
|
|
207
239
|
<T>(path: string) =>
|
|
208
240
|
(found: T): Found<T> => ({ ...found, path })
|
|
209
241
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
242
|
+
/** The ids of the phases the plan had before the change; none for a plan the change creates. */
|
|
243
|
+
const previousPhaseIds = ({ previous }: Changed, language: PlanLanguage): Set<string> =>
|
|
244
|
+
previous ? phaseIdsOfPlan(previous, language) : new Set()
|
|
245
|
+
|
|
246
|
+
const gatesOf = (file: Changed, language: PlanLanguage): Found<PhaseWithoutGate>[] =>
|
|
247
|
+
newPhasesWithoutReviewGate(file.current, language, previousPhaseIds(file, language)).map(withPath(file.path))
|
|
248
|
+
|
|
249
|
+
const purposesOf = (file: Changed, language: PlanLanguage): Found<PhaseWithoutPurpose>[] =>
|
|
250
|
+
newPhasesWithoutPurpose(file.current, language, previousPhaseIds(file, language)).map(withPath(file.path))
|
|
214
251
|
|
|
215
252
|
const rowsOf = ({ path, current, previousLines }: Changed, language: PlanLanguage): Found<RowWithoutPhase>[] =>
|
|
216
253
|
boardRowsWithoutPhase(current, language, previousLines).map(withPath(path))
|
|
@@ -219,17 +256,26 @@ const proseOf = ({ path, current, previousLines }: Changed, language: PlanLangua
|
|
|
219
256
|
boardRowsWithProse(current, language, previousLines).map(withPath(path))
|
|
220
257
|
|
|
221
258
|
/** Every readable copy of the plans the board links, in each plan root; a path that leaves the repository is skipped. */
|
|
222
|
-
const planTextsOf = (
|
|
223
|
-
|
|
259
|
+
const planTextsOf = (
|
|
260
|
+
roots: readonly string[],
|
|
261
|
+
{ path, current }: Changed,
|
|
262
|
+
languages: { board: PlanLanguage; plan: PlanLanguage },
|
|
263
|
+
): string[] =>
|
|
264
|
+
boardPlansOf(current, posix.normalize(dirname(path)), languages.board)
|
|
224
265
|
.filter(isInsideRepository)
|
|
225
266
|
.flatMap((plan) => planCopiesIn(roots, plan))
|
|
226
267
|
|
|
227
|
-
|
|
268
|
+
/** The board is read in the board language, the plans it links in the plan language. */
|
|
269
|
+
const bareCodesOfBoard = (
|
|
270
|
+
roots: readonly string[],
|
|
271
|
+
file: Changed,
|
|
272
|
+
languages: { board: PlanLanguage; plan: PlanLanguage },
|
|
273
|
+
): Found<RowWithBareCode>[] =>
|
|
228
274
|
boardRowsWithBareCodes(
|
|
229
275
|
file.current,
|
|
230
|
-
|
|
276
|
+
languages.board,
|
|
231
277
|
file.previousLines,
|
|
232
|
-
codesOfPlans(planTextsOf(roots, file,
|
|
278
|
+
codesOfPlans(planTextsOf(roots, file, languages), languages.plan),
|
|
233
279
|
).map(withPath(file.path))
|
|
234
280
|
|
|
235
281
|
const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<PhaseWithOpenBoxes>[] =>
|
|
@@ -297,7 +343,7 @@ const phaseBuiltBy = (input: LaneBoxesInput): BuiltPhase | null => {
|
|
|
297
343
|
if (!phase || !step?.planHref) return null
|
|
298
344
|
// with the base known, a phase this checkout did not tick further waits in the row; it is not what the lane builds
|
|
299
345
|
if (phase.work === undefined && input.baseOf(pathOf(step.planHref)) !== null) return null
|
|
300
|
-
const partial = step.label.
|
|
346
|
+
const partial = namesState(step.label, CELL_KEY.partialPhase, PARTIAL_PHASE_WORDS)
|
|
301
347
|
return { planPath: pathOf(step.planHref), phaseId: phase.id, partial }
|
|
302
348
|
}
|
|
303
349
|
|
|
@@ -341,7 +387,8 @@ export const laneOpenBoxesCheck = (input: LaneBoxesInput & { level: OpenBoxesLev
|
|
|
341
387
|
|
|
342
388
|
/**
|
|
343
389
|
* The structure of the changed plans and boards:
|
|
344
|
-
* - a phase the change added (against `HEAD`) with tasks and without a review gate fails
|
|
390
|
+
* - a phase the change added (against `HEAD`) with tasks and without a review gate fails, and so does one without a
|
|
391
|
+
* purpose line directly under its heading;
|
|
345
392
|
* - a lane row the change added or edited that names no phase warns, and so does one whose phases cell is longer
|
|
346
393
|
* than `PHASE_CELL_LIMIT` or records a side order of the user;
|
|
347
394
|
* - a waiting row the change added or edited whose item cell names a plan code of the board's plans bare warns;
|
|
@@ -357,15 +404,20 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
357
404
|
const files = changedPlansOf(input.paths, input.planDirectory)
|
|
358
405
|
if (files.length === 0) return []
|
|
359
406
|
const { language } = input
|
|
407
|
+
const boardLanguage = input.boardLanguage ?? language
|
|
360
408
|
const started = Date.now()
|
|
361
409
|
const changed = changedContents(input, files)
|
|
362
410
|
const boards = changed.filter(({ board }) => board)
|
|
363
411
|
const plans = changed.filter(({ board }) => !board)
|
|
364
412
|
const unlistedPerPlan = plans.map((file) => unlistedOf(file, language)).filter((found) => found !== null)
|
|
365
413
|
const gates = changed.flatMap((file) => gatesOf(file, language))
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
const
|
|
414
|
+
const purposes = changed.flatMap((file) => purposesOf(file, language))
|
|
415
|
+
const sections = changed.flatMap(sectionsOf)
|
|
416
|
+
const rows = boards.flatMap((file) => rowsOf(file, boardLanguage))
|
|
417
|
+
const prose = boards.flatMap((file) => proseOf(file, boardLanguage))
|
|
418
|
+
const bareCodes = boards.flatMap((file) =>
|
|
419
|
+
bareCodesOfBoard(input.planRoots ?? [input.root], file, { board: boardLanguage, plan: language }),
|
|
420
|
+
)
|
|
369
421
|
const merged = plans.flatMap((file) => mergedOf(file, language))
|
|
370
422
|
const unlisted = unlistedPerPlan.flat()
|
|
371
423
|
const shortNames = plans.flatMap((file) => shortNamesOf(file, language))
|
|
@@ -373,6 +425,7 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
373
425
|
const durationMs = Date.now() - started
|
|
374
426
|
return [
|
|
375
427
|
findingsResult(gateReport(language), gates, files.length, durationMs),
|
|
428
|
+
findingsResult(purposeReport(language), purposes, files.length, durationMs),
|
|
376
429
|
...(boards.length > 0
|
|
377
430
|
? [
|
|
378
431
|
findingsResult(rowReport, rows, boards.length, durationMs),
|
|
@@ -390,5 +443,6 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
|
390
443
|
findingsResult(titleReport, titles, plans.length, durationMs),
|
|
391
444
|
]
|
|
392
445
|
: []),
|
|
446
|
+
findingsResult(sectionReport, sections, files.length, durationMs),
|
|
393
447
|
]
|
|
394
448
|
}
|
package/src/check/settings.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type CheckSettings = {
|
|
|
21
21
|
check: CheckConfig
|
|
22
22
|
planDirectory: string
|
|
23
23
|
planLanguage: PlanLanguage
|
|
24
|
+
/** The language the boards are written in: the config's `boardLanguage`, or `planLanguage` when it sets none. */
|
|
25
|
+
boardLanguage: PlanLanguage
|
|
24
26
|
/** The branch on the remote whose plans count as merged. */
|
|
25
27
|
baseBranch: string
|
|
26
28
|
/** Where the lane worktrees live, below the main checkout: a board's plans are read there too. */
|
|
@@ -44,6 +46,7 @@ export const noStackFallbackOf = ({ stack, check, commands }: CheckSettings): No
|
|
|
44
46
|
const commandsSchema = jackherdConfigSchema.shape.commands
|
|
45
47
|
const planDirectorySchema = jackherdConfigSchema.shape.planDirectory
|
|
46
48
|
const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
|
|
49
|
+
const boardLanguageSchema = jackherdConfigSchema.shape.boardLanguage
|
|
47
50
|
const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
|
|
48
51
|
const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
|
|
49
52
|
const skillPrefixSchema = jackherdConfigSchema.shape.skillPrefix
|
|
@@ -58,11 +61,13 @@ export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckS
|
|
|
58
61
|
const detected = withDetected(z.record(z.string(), z.unknown()).parse(raw), facts)
|
|
59
62
|
const object = z.record(z.string(), z.unknown()).parse(detected.raw)
|
|
60
63
|
const { test, typecheck, lint } = commandsSchema.parse(object.commands)
|
|
64
|
+
const planLanguage = planLanguageSchema.parse(object.planLanguage)
|
|
61
65
|
return {
|
|
62
66
|
commands: { test, typecheck, lint },
|
|
63
67
|
check: checkConfigSchema.parse(object.check ?? {}),
|
|
64
68
|
planDirectory: planDirectorySchema.parse(object.planDirectory),
|
|
65
|
-
planLanguage
|
|
69
|
+
planLanguage,
|
|
70
|
+
boardLanguage: boardLanguageSchema.parse(object.boardLanguage) ?? planLanguage,
|
|
66
71
|
baseBranch: baseBranchSchema.parse(object.baseBranch),
|
|
67
72
|
worktreeDirectory: worktreeDirectorySchema.parse(object.worktreeDirectory),
|
|
68
73
|
board: boardSchema.parse(object.board),
|
|
@@ -74,7 +74,8 @@ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
|
|
|
74
74
|
'.claude/skills/orchestrate/references/waiting.md': 216,
|
|
75
75
|
'.claude/skills/orchestrate/references/watch-without-monitor.md': 154,
|
|
76
76
|
// Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
|
|
77
|
-
|
|
77
|
+
// Language split: the note that a template renders in the language of its file name and that a heading in any other language carries a marker (+27).
|
|
78
|
+
'.claude/skills/plan/SKILL.md': 1744,
|
|
78
79
|
'.claude/skills/plan/references/template-English.md': 536,
|
|
79
80
|
'.claude/skills/plan/references/template-German.md': 501,
|
|
80
81
|
// Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
|
|
@@ -111,9 +112,11 @@ export const KIT_SKILL_CEILINGS: Readonly<Record<string, number>> = {
|
|
|
111
112
|
'.claude/agents/skill-review.md': 513,
|
|
112
113
|
// Plan head: every plan opens with a `For you` head of five short lines a person reads first, which
|
|
113
114
|
// `jh plan check` reports; cut to its labels; skill-review: the status line says what `N` counts. Measured size (+42).
|
|
114
|
-
|
|
115
|
+
// Phase purpose: the line under every phase heading a template shows, one sentence of what is different afterwards and for whom (+17).
|
|
116
|
+
'.claude/skills/plan/references/template-English.md': 595,
|
|
115
117
|
// Plan head: the same `Für dich` head as the English template, with what `N` counts; measured size (+41).
|
|
116
|
-
|
|
118
|
+
// Phase purpose: the same line under every phase heading as the English template (+15).
|
|
119
|
+
'.claude/skills/plan/references/template-German.md': 557,
|
|
117
120
|
// P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
|
|
118
121
|
// Plain words: no plan code without its words in a short question.
|
|
119
122
|
// Any language: a pointer in § 0 and a section in events.md for `plan missing`.
|
package/src/config/schema.ts
CHANGED
|
@@ -567,7 +567,10 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
567
567
|
responseLanguage: language.default(PLAN_LANGUAGE.en),
|
|
568
568
|
/** Whether replies address the user with the familiar or the polite form where the language has both (German: du or Sie). */
|
|
569
569
|
responseAddress: z.enum(Object.values(RESPONSE_ADDRESS)).default(RESPONSE_ADDRESS.informal),
|
|
570
|
+
/** The language new plans and the plan templates are written in. Boards and plans are read in every known language whatever this says. */
|
|
570
571
|
planLanguage: language.default(PLAN_LANGUAGE.en),
|
|
572
|
+
/** The language jh writes the board in (history lines, lane rows, waiting rows); `planLanguage` when left out. */
|
|
573
|
+
boardLanguage: language.optional(),
|
|
571
574
|
planDirectory: z.string().min(1).default('docs/plans'),
|
|
572
575
|
/** Where the boards live and what the project calls them. */
|
|
573
576
|
board: boardSchema.prefault({}),
|
package/src/lane/close-board.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { twoDigits } from '../plan-format/clock'
|
|
2
2
|
import { laneNameOf, parseSprintLanes } from '../plan-format/lanes'
|
|
3
|
-
import { firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
|
|
3
|
+
import { firstTableRows, namesState, sectionLines, sectionStart } from '../plan-format/markdown'
|
|
4
4
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
|
-
import { PLAN_LANGUAGE, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
5
|
+
import { CELL_KEY, LANE_CLOSED_WORDS, PLAN_LANGUAGE, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
6
6
|
import { BOARD_LANE, type BoardLane } from './close-check'
|
|
7
7
|
|
|
8
8
|
const CONTINUATION = '↳'
|
|
@@ -38,7 +38,7 @@ export const boardLaneOf = (board: string, lane: string, language: PlanLanguage)
|
|
|
38
38
|
* a row that names the lane again without the mark starting it anew. Empty when the lane is closed or missing.
|
|
39
39
|
*/
|
|
40
40
|
const currentRowIndexes = (board: string, lane: string, language: PlanLanguage): number[] => {
|
|
41
|
-
const { sections
|
|
41
|
+
const { sections } = PLAN_VOCABULARY[language]
|
|
42
42
|
const start = sectionStart(board, sections.lanes)
|
|
43
43
|
if (start === -1) return []
|
|
44
44
|
let previous: string | null = null
|
|
@@ -49,7 +49,7 @@ const currentRowIndexes = (board: string, lane: string, language: PlanLanguage):
|
|
|
49
49
|
const name = laneNameOf(first, previous)
|
|
50
50
|
previous = name ?? previous
|
|
51
51
|
if (name !== lane) continue
|
|
52
|
-
const marked = first.
|
|
52
|
+
const marked = namesState(first, CELL_KEY.laneClosed, LANE_CLOSED_WORDS)
|
|
53
53
|
if (closed && !marked && !first.startsWith(CONTINUATION)) {
|
|
54
54
|
closed = false
|
|
55
55
|
indexes = []
|
|
@@ -185,7 +185,7 @@ const surveyOf = async (lane: string, branch: string | null, context: CloseConte
|
|
|
185
185
|
const present = entry !== null && existsSync(context.worktree)
|
|
186
186
|
const facts: CloseFacts = {
|
|
187
187
|
lane,
|
|
188
|
-
boardLane: boardLaneOf(readFileSync(context.boardFile, 'utf8'), lane, context.settings.
|
|
188
|
+
boardLane: boardLaneOf(readFileSync(context.boardFile, 'utf8'), lane, context.settings.boardLanguage),
|
|
189
189
|
setupFiles: setupFilesOf(context.root),
|
|
190
190
|
...agentFactsOf(laneStatusSourceFor(context.settings.adapter).listAgents(), lane, context.worktree),
|
|
191
191
|
runsInside: realPath(worktreeRoot(context.cwd)) === realPath(context.worktree),
|
|
@@ -267,7 +267,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
|
|
|
267
267
|
boardPath: relative(context.root, context.boardFile),
|
|
268
268
|
dryRun: options.dryRun,
|
|
269
269
|
now: new Date(),
|
|
270
|
-
language: context.settings.
|
|
270
|
+
language: context.settings.boardLanguage,
|
|
271
271
|
}
|
|
272
272
|
return await closeLane(request, portsFor(options.lane, options.branch, context))
|
|
273
273
|
} catch (error) {
|
package/src/lane/dev-command.ts
CHANGED
|
@@ -26,7 +26,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
|
|
|
26
26
|
const settings = readSettings([root, worktreeRoot(cwd)])
|
|
27
27
|
const board = boardFor(boardPath === null ? null : resolve(cwd, boardPath), [root], settings, () => [])
|
|
28
28
|
if (board.notice) console.error(terminalSafe(board.notice))
|
|
29
|
-
const row = parseSprintLanes(readFileSync(board.file, 'utf8'), settings.
|
|
29
|
+
const row = parseSprintLanes(readFileSync(board.file, 'utf8'), settings.boardLanguage).find(
|
|
30
30
|
({ name, closed }) => name === lane && !closed,
|
|
31
31
|
)
|
|
32
32
|
const port = devPortOf(portOption, row?.port ?? null)
|
package/src/lane/port-command.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
|
|
|
25
25
|
const settings = readSettings([root, worktreeRoot(cwd)])
|
|
26
26
|
const board = boardFor(boardPath === null ? null : resolve(cwd, boardPath), [root], settings, () => [])
|
|
27
27
|
if (board.notice) console.error(terminalSafe(board.notice))
|
|
28
|
-
const reserved = portsOfRunningLanes(parseSprintLanes(readFileSync(board.file, 'utf8'), settings.
|
|
28
|
+
const reserved = portsOfRunningLanes(parseSprintLanes(readFileSync(board.file, 'utf8'), settings.boardLanguage))
|
|
29
29
|
const port = await freePortNotListening(settings.basePort, reserved, MAX_PORT, isPortListening)
|
|
30
30
|
if (port === null) throw new Error(`no free port from ${settings.basePort} to ${MAX_PORT}`)
|
|
31
31
|
console.log(port)
|
|
@@ -23,7 +23,8 @@ export const USAGE = `usage: ${CLI_NAME} plan ${CHECK_SUBCOMMAND} <plan.md>
|
|
|
23
23
|
|
|
24
24
|
Checks one plan against the plan contract: phases, tasks in every phase, a model for every phase, a dependency
|
|
25
25
|
row for every phase (from two phases on). One line per part, \`file:line\` where a part is incomplete.
|
|
26
|
-
A fifth line, \`head:\` present, missing or stale, reports the plan's "For you" head;
|
|
26
|
+
A fifth line, \`head:\` present, missing or stale, reports the plan's "For you" head; a sixth, \`purpose:\` present or missing,
|
|
27
|
+
reports whether every phase opens with its one-sentence purpose line. Both are notes and never change the exit code.
|
|
27
28
|
Where git has the base branch, a plan, or a board that links it, that the base branch lacks gets a \`base:\` line with its path.
|
|
28
29
|
The plan language comes from .jackherd/config.json.
|
|
29
30
|
Exit 0 met, 1 with gaps, 2 for a wrong command line or an unreadable file, 3 met but not on the base branch.`
|