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
package/docs/plan-contract.md
CHANGED
|
@@ -11,6 +11,7 @@ jackherd reads a plan from its Markdown. This is what a plan has to hold for the
|
|
|
11
11
|
| Model | a table under `### Model recommendations per phase`: the phase in the first cell (`1 — Title`, `P1`, `10c`), the model in the second (`claude-opus-5-5`, `Opus 5.5`); a dash marks a phase that needs no model | yes | lane start (the model a lane runs on), `/implement` |
|
|
12
12
|
| Dependencies | a table under `### Dependencies and parallelization` with the columns `Depends on`, `Writes`, `Serial marker` and one row per phase | from two phases on, optional | `jh check --parallel`, `/implement` |
|
|
13
13
|
| Head | a section `## For you` (de: `## Für dich`) right after the header lines (`**Date:**`, `**Status:**`, `**Scope:**`), before the first other `##` section, with five lines `- **<label>:** <text>` | no, a note | `jh plan check`, the reader who is not an agent |
|
|
14
|
+
| Purpose | directly under every `### Phase` heading, one line `**What it is about:** <sentence>` (de: `**Worum es geht:** <Satz>`): what is different afterwards, for whom | no, a note | `jh plan check`, `jh check` (new phases), the herd |
|
|
14
15
|
|
|
15
16
|
Details that decide a verdict:
|
|
16
17
|
|
|
@@ -21,7 +22,47 @@ Details that decide a verdict:
|
|
|
21
22
|
- The head's five labels are fixed per language. English: `What changes for you`, `Status`, `Your open decisions`, `Try it now`, `Next step`; German: `Was sich für dich ändert`, `Stand`, `Deine offenen Entscheidungen`, `Schon ausprobieren`, `Nächster Schritt`. The colon may stand outside the bold (`**Status**:`) and the list mark may be left out.
|
|
22
23
|
- The `Status` line carries `Phase x/y`: x the id of the first phase with an open checkbox, as its heading spells it (`3`, `3a`), y the number of phases, each id counted once. When every phase is done, x is the id of the last one.
|
|
23
24
|
- The head is `missing` without the section. It is `stale` when it stands behind another section, lacks a line, has no `Phase x/y`, or names another phase or phase count than the checkboxes show. Both are notes: the contract stays met and the exit code does not change, so plans written before the head keep passing.
|
|
24
|
-
- The headings
|
|
25
|
+
- The purpose line counts when it is the first non-empty line under a `### Phase` heading and has text after the colon; the colon may stand outside the bold or be left out. A line behind the origin line does not count. Only `### Phase` headings are read; phases headed `## 1 — …` or `## P1 — …` and the closing `## N — …` section are not. `purpose` is `missing` (`no purpose line in P2, P3`) when a phase with checkboxes of its own lacks the line (a phase that only points at a section needs none); it is a note and the exit code does not change, so older plans keep passing. `jh check` goes further for a phase the change adds: it fails without the line; phases the plan already had are not read.
|
|
26
|
+
- The headings are shown in English here; a plan or board is read in German as well, and in any other language by markers (see below).
|
|
27
|
+
|
|
28
|
+
## Language and markers
|
|
29
|
+
|
|
30
|
+
jackherd reads boards and plans in every language it has words for (English and German), whatever `.jackherd/config.json` says: a German board next to an English plan works, and so does the other way round. The configuration decides only what jh **writes**:
|
|
31
|
+
|
|
32
|
+
| Key | Decides | Default |
|
|
33
|
+
| --- | ------- | ------- |
|
|
34
|
+
| `planLanguage` | the language of new plans and of the plan templates | `en` |
|
|
35
|
+
| `boardLanguage` | the language jh writes the board in: history lines, lane rows, waiting rows | the value of `planLanguage` |
|
|
36
|
+
|
|
37
|
+
A config without `boardLanguage` runs as before. A heading, column name or status word is looked up in this order: the marker, the word of the configured language, the word of the other language.
|
|
38
|
+
|
|
39
|
+
A document in a language jackherd has no words for carries a **marker**, `<!-- jh: <key> -->`, which Markdown does not show. The key comes from a closed set; an unknown key is ignored.
|
|
40
|
+
|
|
41
|
+
| Where the marker stands | Keys |
|
|
42
|
+
| ----------------------- | ---- |
|
|
43
|
+
| on the line directly under a `##` (or `###`) heading | `waiting`, `appointments`, `lanes`, `goal`, `history`, `plans`, `decisions`, `implementation`, `model`, `dependencies`, `documentation`, `for-you`, `next` |
|
|
44
|
+
| in a header cell of a table | `since`, `item`, `origin`, `unblocks`, `status`, `when`, `action`, `depends-on`, `writes`, `serial-marker` |
|
|
45
|
+
| on a line of the head | `head-changes`, `head-status`, `head-decisions`, `head-try-now`, `head-next` |
|
|
46
|
+
| in a status cell | `status-open` (a board row that still waits), `status-done`, `status-abandoned`, `status-in-progress`, `status-draft` |
|
|
47
|
+
| on a line that opens with bold words | `no-review-gate`, `purpose` |
|
|
48
|
+
| in a lane's name cell or phases cell | `lane-closed`, `partial-phase` |
|
|
49
|
+
|
|
50
|
+
```markdown
|
|
51
|
+
## Pistes
|
|
52
|
+
<!-- jh: lanes -->
|
|
53
|
+
|
|
54
|
+
| Piste | Port | Plan |
|
|
55
|
+
| ----- | ---- | ---- |
|
|
56
|
+
| `checkout` nettoyé <!-- jh: lane-closed --> | 3200 | [Plan](checkout.md) |
|
|
57
|
+
|
|
58
|
+
### Phase 1: Surface
|
|
59
|
+
|
|
60
|
+
**Le but** <!-- jh: purpose --> Les clients voient leurs filtres en haut de la liste.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
When no section of a changed plan or board is recognised, three places say so: `jh plan check` names the forms it looked for (`no model table (looked for "### Model recommendations per phase", "### Model-Empfehlungen pro Phase" or <!-- jh: model -->)`), `jh check` warns on a changed file that has `##` headings of which jh reads none, and the herd shows `board: no section recognised` instead of "nothing waits for you". A section that is absent but allowed (no waiting item) stays silent.
|
|
64
|
+
|
|
65
|
+
The plan templates are written in the language of their file name: `template-English.md` always in English, `template-German.md` always in German, whatever `planLanguage` says.
|
|
25
66
|
|
|
26
67
|
## Minimal valid plan
|
|
27
68
|
|
|
@@ -49,11 +90,15 @@ Details that decide a verdict:
|
|
|
49
90
|
|
|
50
91
|
### Phase 1: Query parser
|
|
51
92
|
|
|
93
|
+
**What it is about:** nothing visible yet; the search box can turn typed text into filters.
|
|
94
|
+
|
|
52
95
|
- [ ] **Parse filters**: `parseQuery(text)` returns the filter list
|
|
53
96
|
- [ ] **Test it**: every operator, one malformed input
|
|
54
97
|
|
|
55
98
|
### Phase 2: Filter bar
|
|
56
99
|
|
|
100
|
+
**What it is about:** people who search see their filters as chips above the results and can remove each one.
|
|
101
|
+
|
|
57
102
|
- [ ] **Render the bar**: one chip per filter
|
|
58
103
|
```
|
|
59
104
|
|
|
@@ -70,22 +115,24 @@ phases: present plan.md:22 2 phases
|
|
|
70
115
|
tasks: present plan.md:22 every phase has tasks
|
|
71
116
|
model: present plan.md:12 every phase has a model
|
|
72
117
|
dependencies: present every phase has a row
|
|
73
|
-
head: missing no `## For you` section
|
|
118
|
+
head: missing no `## For you` section (looked for "## For you", "## Für dich" or <!-- jh: for-you -->)
|
|
119
|
+
purpose: present every phase says what it is about
|
|
74
120
|
```
|
|
75
121
|
|
|
76
|
-
If the model table lacked the row for phase 2,
|
|
122
|
+
If the model table lacked the row for phase 2, the dependency table were left out and the phases had no purpose lines, the lines would read:
|
|
77
123
|
|
|
78
124
|
```
|
|
79
125
|
phases: present plan.md:22 2 phases
|
|
80
126
|
tasks: present plan.md:22 every phase has tasks
|
|
81
127
|
model: malformed plan.md:22 no model for P2
|
|
82
|
-
dependencies: missing no dependency table: phases run one after another
|
|
128
|
+
dependencies: missing no dependency table: phases run one after another (looked for "### Dependencies and parallelization", "### Abhängigkeiten und Parallelisierung" or <!-- jh: dependencies -->)
|
|
83
129
|
head: stale plan.md:5 names phase 1, the running phase is 2
|
|
130
|
+
purpose: missing plan.md:22 no purpose line in P1, P2
|
|
84
131
|
```
|
|
85
132
|
|
|
86
133
|
| Exit | Meaning |
|
|
87
134
|
| ---- | ------- |
|
|
88
|
-
| 0 | the contract is met (a missing dependency table
|
|
135
|
+
| 0 | the contract is met (a missing dependency table, a missing or stale head and a missing purpose line are notes, not gaps) |
|
|
89
136
|
| 1 | at least one part is missing or malformed |
|
|
90
137
|
| 2 | wrong command line: no subcommand, unknown subcommand, no file, unreadable file, unreadable config |
|
|
91
138
|
| 3 | the contract is met, but the plan or a board that links it is not on the base branch (a `base:` line names each path); the coordinator commits it first (`/orchestrate` § 2 step 0), after which the check counts as done |
|
package/install.sh
CHANGED
|
@@ -9,7 +9,7 @@ set -eu
|
|
|
9
9
|
|
|
10
10
|
# The default is the version this file ships with. The release bump changes it together with
|
|
11
11
|
# package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
|
|
12
|
-
JACKHERD_VERSION="${JACKHERD_VERSION:-1.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.25.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -59,11 +59,11 @@ const writeHistory = (main: string, entry: StandingHistoryEntry): boolean => {
|
|
|
59
59
|
try {
|
|
60
60
|
const location = boardLocationOf(readConfig([main]))
|
|
61
61
|
if (location === null) return false
|
|
62
|
-
const board = boardForLane(boardsIn(main, location), entry.lane, location.
|
|
62
|
+
const board = boardForLane(boardsIn(main, location), entry.lane, location.boardLanguage)
|
|
63
63
|
if (board === null) return false
|
|
64
64
|
const path = join(main, board.directory, location.board.file)
|
|
65
65
|
const text = readTextOrNull(path)
|
|
66
|
-
const next = text === null ? null : withHistoryRow(text, location.
|
|
66
|
+
const next = text === null ? null : withHistoryRow(text, location.boardLanguage, historyDate(entry.at), standingHistoryLine(entry))
|
|
67
67
|
if (next === null) return false
|
|
68
68
|
writeFileSync(path, next)
|
|
69
69
|
return true
|
|
@@ -47,5 +47,5 @@ const worktreeListOf = (main: string): string | undefined => {
|
|
|
47
47
|
export const planOfBranch = (main: string, settings: BoardSettings, branch: string): string | undefined => {
|
|
48
48
|
const list = worktreeListOf(main)
|
|
49
49
|
const lane = list === undefined ? undefined : laneInWorktreeList(list, main, settings.worktreeDirectory, branch)
|
|
50
|
-
return lane === undefined ? undefined : onlyPlanOf(boardsIn(main, settings), lane, settings.
|
|
50
|
+
return lane === undefined ? undefined : onlyPlanOf(boardsIn(main, settings), lane, settings.boardLanguage)
|
|
51
51
|
}
|
|
@@ -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. */
|
|
@@ -86,7 +86,8 @@ through steps 0 to 8. One line to the user afterwards.
|
|
|
86
86
|
## 3. Dev server
|
|
87
87
|
|
|
88
88
|
A lane whose phase changes something a user sees gets its dev server **when the lane starts**:
|
|
89
|
-
[`references/start-lane.md`](references/start-lane.md).
|
|
89
|
+
[`references/start-lane.md`](references/start-lane.md); with `lanes.backend`, the services of the shared backend:
|
|
90
|
+
[`references/backend.md`](references/backend.md).
|
|
90
91
|
|
|
91
92
|
## 4. Watch
|
|
92
93
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# § 3: the shared backend of `lanes.backend`
|
|
2
|
+
|
|
3
|
+
Read on a `backend:` watcher line, or at a lane's start (`start-lane.md` § 3) when `lanes.backend`
|
|
4
|
+
is set. The services every lane's app talks to run in one pane of a tab in the main checkout; the watcher starts and
|
|
5
|
+
keeps it. Without `lanes.backend` nothing here applies.
|
|
6
|
+
|
|
7
|
+
## At a lane's start
|
|
8
|
+
|
|
9
|
+
When the phase's Writes column names a path of one of those services, hand that service to the lane first, from the
|
|
10
|
+
main checkout: `jh backend release <service> --lane <lane>` restarts the backend pane without it and prints the freed
|
|
11
|
+
port; then `jh lane dev <lane> --filter <service> --port <port>`. Only you call `jh backend release`, from the main
|
|
12
|
+
checkout; a lane that finds out later asks in its report. Before `jh lane close`, stop the lane's dev server, then
|
|
13
|
+
`jh backend return <service>`; done when it prints that the service returned. Skipped, the watcher returns it once the
|
|
14
|
+
worktree is gone.
|
|
15
|
+
|
|
16
|
+
## Watcher lines
|
|
17
|
+
|
|
18
|
+
Each line is said once while it stands, without the `backend: ` prefix. A `kit updated` leaves the pane running.
|
|
19
|
+
|
|
20
|
+
| Line | What to do |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `started in <pane>` | Nothing. |
|
|
23
|
+
| `port <n> in use, not started` | Find the holder as in `start-lane.md` § 3; a lane's server for a handed-over service (`jh backend` lists them) is the lane's, any other goes to the user. The pane starts once the port is free. |
|
|
24
|
+
| `running command differs from lanes.backend` | The config changed after the start. Tell the user once; `jh backend restart` after they agree, since it stops the services. |
|
|
25
|
+
| `gave up after 3 starts in 10 minutes` | The command ends right after it starts. Read the pane, tell the user the cause; `jh backend restart` once fixed. |
|
|
26
|
+
| `<service> returned from <lane> (worktree gone)` | Nothing. |
|
|
27
|
+
| `backend check failed, still watching: <reason>` | Fix the cause (herdr answering, one workspace of the main checkout, `lanes.backend` readable); done when `jh backend` shows the pane. |
|
|
28
|
+
| `… left alone` (several panes with the label, an agent in the pane, a pane outside the main checkout) | Tell the user; the pane is theirs. |
|
|
29
|
+
|
|
30
|
+
`jh backend` only ever stops the process in the backend pane; a pane it cannot read goes to the user.
|
|
@@ -9,6 +9,7 @@ or when the user names a lane to close. Run both commands from the main checkout
|
|
|
9
9
|
nothing: resolve each reason it prints (a named file goes to the user first, a working agent is waited for, a lock
|
|
10
10
|
is lifted once its session ended, an unmerged branch is not closed) and repeat until it exits 0.
|
|
11
11
|
A worktree on no branch closes when its head is on the base branch and only staged plan changes the base holds are left.
|
|
12
|
+
Handed-over service: [`backend.md`](backend.md).
|
|
12
13
|
2. `jh lane close <lane>` Run it without asking the user; its checks are the safeguard. It repeats the step 1 checks (resolve a refusal as in step 1) and runs the listed steps, one line each. After a failure,
|
|
13
14
|
run it again: with the worktree gone and the branch left, `jh lane close <lane> --branch <branch>` finishes the
|
|
14
15
|
branch and the board; a lane closed in full says so and changes nothing.
|
|
@@ -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
|
|
@@ -62,9 +62,9 @@ herdr pane run <new-pane> "PORT=<port> {{kit.command.dev}}"
|
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
The new pane starts in the lane's worktree; check with `/proc/<pid>/cwd` (macOS: `lsof -p <pid>`) that the server runs
|
|
65
|
-
there,
|
|
65
|
+
there, also before stopping one by PID. Stop only a server you started; another's port
|
|
66
66
|
goes to the user.
|
|
67
67
|
|
|
68
68
|
To restart it after a rebase, stop it by PID (found through the port) once `/proc/<pid>/cwd` (macOS: `lsof -p <pid>`) shows
|
|
69
|
-
the lane's worktree, then `herdr pane run <pane> "<the same command>"` in the same pane. Each step is
|
|
69
|
+
the lane's worktree, then `herdr pane run <pane> "<the same command>"` in the same pane. Each step is its own call and
|
|
70
70
|
passes the pane guard without a question; keys typed into the pane ask the user.
|
|
@@ -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
|
@@ -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",
|
|
@@ -156,6 +163,48 @@
|
|
|
156
163
|
"devCommand": {
|
|
157
164
|
"type": "string",
|
|
158
165
|
"minLength": 1
|
|
166
|
+
},
|
|
167
|
+
"backend": {
|
|
168
|
+
"type": "object",
|
|
169
|
+
"properties": {
|
|
170
|
+
"command": {
|
|
171
|
+
"type": "string",
|
|
172
|
+
"minLength": 1
|
|
173
|
+
},
|
|
174
|
+
"services": {
|
|
175
|
+
"minItems": 1,
|
|
176
|
+
"type": "array",
|
|
177
|
+
"items": {
|
|
178
|
+
"type": "object",
|
|
179
|
+
"properties": {
|
|
180
|
+
"name": {
|
|
181
|
+
"type": "string",
|
|
182
|
+
"pattern": "^(?!-)[\\w@./:-]+$"
|
|
183
|
+
},
|
|
184
|
+
"port": {
|
|
185
|
+
"type": "integer",
|
|
186
|
+
"minimum": 1,
|
|
187
|
+
"maximum": 65535
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
"required": [
|
|
191
|
+
"name",
|
|
192
|
+
"port"
|
|
193
|
+
],
|
|
194
|
+
"additionalProperties": false
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
"label": {
|
|
198
|
+
"default": "backend",
|
|
199
|
+
"type": "string",
|
|
200
|
+
"pattern": "^(?!-)[\\w.-]+$"
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
"required": [
|
|
204
|
+
"command",
|
|
205
|
+
"services"
|
|
206
|
+
],
|
|
207
|
+
"additionalProperties": false
|
|
159
208
|
}
|
|
160
209
|
},
|
|
161
210
|
"additionalProperties": false
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readConfig } from '../config/read-config'
|
|
2
|
+
import { TERMINAL_ADAPTER } from '../config/schema'
|
|
3
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
+
import { mainCheckout } from '../plans/git'
|
|
5
|
+
import { asksForHelp } from '../preview/options'
|
|
6
|
+
import { CLI_NAME } from '../version/release'
|
|
7
|
+
import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
8
|
+
import {
|
|
9
|
+
type BackendOutcome,
|
|
10
|
+
type BackendSystem,
|
|
11
|
+
releaseService,
|
|
12
|
+
restartBackend,
|
|
13
|
+
returnService,
|
|
14
|
+
statusLines,
|
|
15
|
+
} from './control'
|
|
16
|
+
import { withBackendLock } from './lock'
|
|
17
|
+
import type { BackendConfig } from './schema'
|
|
18
|
+
import { backendFilesOf, backendSystemOf } from './system'
|
|
19
|
+
|
|
20
|
+
/** The exit codes of `jh backend`: done, refused, and a call it does not understand. */
|
|
21
|
+
export const BACKEND_EXIT = { done: 0, refused: 1, usage: 2 } as const
|
|
22
|
+
|
|
23
|
+
export const BACKEND_REQUEST = { status: 'status', restart: 'restart', release: 'release', return: 'return' } as const
|
|
24
|
+
|
|
25
|
+
/** What `jh backend` was asked for. */
|
|
26
|
+
export type BackendRequest =
|
|
27
|
+
| { kind: typeof BACKEND_REQUEST.status }
|
|
28
|
+
| { kind: typeof BACKEND_REQUEST.restart }
|
|
29
|
+
| { kind: typeof BACKEND_REQUEST.release; service: string; lane: string }
|
|
30
|
+
| { kind: typeof BACKEND_REQUEST.return; service: string }
|
|
31
|
+
|
|
32
|
+
const LANE = '--lane'
|
|
33
|
+
|
|
34
|
+
/** How long a call waits for the watcher's check, which may itself wait for a stop. */
|
|
35
|
+
const LOCK_WAIT_MS = 90_000
|
|
36
|
+
|
|
37
|
+
export const BACKEND_USAGE = `usage: ${CLI_NAME} backend
|
|
38
|
+
${CLI_NAME} backend restart
|
|
39
|
+
${CLI_NAME} backend release <service> ${LANE} <lane>
|
|
40
|
+
${CLI_NAME} backend return <service>
|
|
41
|
+
|
|
42
|
+
The shared backend of the lanes (lanes.backend): one pane in a tab of the main checkout that the watcher starts and
|
|
43
|
+
keeps. Without arguments it shows the pane, the command last started, the services handed to lanes and the recent
|
|
44
|
+
starts. restart stops the pane, forgets "gave up" and starts it again. release takes a service out of the backend,
|
|
45
|
+
so a lane runs it itself (${CLI_NAME} lane dev <lane> --filter <service> --port <port>); return puts it back. A busy
|
|
46
|
+
service port prevents a start; nothing frees a port.`
|
|
47
|
+
|
|
48
|
+
const usage = (): never => {
|
|
49
|
+
throw new Error(BACKEND_USAGE)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Reads the arguments of `jh backend`; throws with the usage on anything else. */
|
|
53
|
+
export const parseBackendRequest = (args: readonly string[]): BackendRequest => {
|
|
54
|
+
const [kind, service, option, lane, ...rest] = args
|
|
55
|
+
if (kind === undefined) return { kind: BACKEND_REQUEST.status }
|
|
56
|
+
if (kind === BACKEND_REQUEST.restart && args.length === 1) return { kind }
|
|
57
|
+
if (kind === BACKEND_REQUEST.return && service !== undefined && args.length === 2) return { kind, service }
|
|
58
|
+
const isRelease = kind === BACKEND_REQUEST.release && service !== undefined && option === LANE
|
|
59
|
+
if (isRelease && lane !== undefined && rest.length === 0 && SAFE_LANE_NAME.test(lane)) return { kind, service, lane }
|
|
60
|
+
return usage()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Carries out `request` against the backend; the caller holds the lock. */
|
|
64
|
+
export const carryOutRequest = async (
|
|
65
|
+
request: BackendRequest,
|
|
66
|
+
backend: BackendConfig,
|
|
67
|
+
system: BackendSystem,
|
|
68
|
+
): Promise<BackendOutcome> => {
|
|
69
|
+
if (request.kind === BACKEND_REQUEST.restart) return restartBackend(backend, system)
|
|
70
|
+
if (request.kind === BACKEND_REQUEST.release) return releaseService(backend, system, request.service, request.lane)
|
|
71
|
+
if (request.kind === BACKEND_REQUEST.return) return returnService(backend, system, request.service)
|
|
72
|
+
return { ok: true, lines: statusLines(await system.look(), system.readState(), system.now()) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The backend of the main checkout's own config: a lane's worktree must not change the command the main checkout runs. */
|
|
76
|
+
const backendOfMainCheckout = (root: string) => {
|
|
77
|
+
const config = readConfig([root])
|
|
78
|
+
if (config.terminal.adapter !== TERMINAL_ADAPTER.herdr) throw new Error('backend: needs terminal.adapter herdr')
|
|
79
|
+
if (!config.lanes.backend) throw new Error('backend: no lanes.backend in the main checkout .jackherd/config.json')
|
|
80
|
+
return { backend: config.lanes.backend, worktreeDirectory: config.worktreeDirectory }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const print = (lines: readonly string[]) => {
|
|
84
|
+
for (const line of lines) console.log(terminalSafe(line))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `jh backend [restart | release <service> --lane <lane> | return <service>]`: works on the main checkout from any
|
|
89
|
+
* worktree. Exit 0 done, 1 refused, 2 usage.
|
|
90
|
+
*/
|
|
91
|
+
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
|
92
|
+
if (asksForHelp(args)) {
|
|
93
|
+
console.log(BACKEND_USAGE)
|
|
94
|
+
return BACKEND_EXIT.done
|
|
95
|
+
}
|
|
96
|
+
let request: BackendRequest
|
|
97
|
+
try {
|
|
98
|
+
request = parseBackendRequest(args)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
101
|
+
return BACKEND_EXIT.usage
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const root = mainCheckout(cwd)
|
|
105
|
+
const { backend, worktreeDirectory } = backendOfMainCheckout(root)
|
|
106
|
+
const files = backendFilesOf(root)
|
|
107
|
+
const system = backendSystemOf({ root, worktreeDirectory, backend, files })
|
|
108
|
+
const outcome = await withBackendLock(files.lock, LOCK_WAIT_MS, () => carryOutRequest(request, backend, system))
|
|
109
|
+
if (outcome === null) throw new Error('backend: the watcher holds the backend lock; try again in a minute')
|
|
110
|
+
print(outcome.lines)
|
|
111
|
+
return outcome.ok ? BACKEND_EXIT.done : BACKEND_EXIT.refused
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error(terminalSafe(error instanceof Error ? error.message : String(error)))
|
|
114
|
+
return BACKEND_EXIT.refused
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type BackendConfig, SERVICES_PLACEHOLDER } from './schema'
|
|
2
|
+
|
|
3
|
+
/** Frees the lanes' `PORT` for the services: a service reading `PORT ?? <NAME>_PORT` must not bind a lane's port. */
|
|
4
|
+
export const BACKEND_ENVIRONMENT_PREFIX = 'env -u PORT '
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shell line of the backend pane: the configured command without `PORT`, with `{services}` filled by the services
|
|
8
|
+
* not handed to a lane, in configuration order. Null when none is left to run.
|
|
9
|
+
*/
|
|
10
|
+
export const backendCommandOf = (backend: BackendConfig, released: readonly string[]): string | null => {
|
|
11
|
+
const names = backend.services.map(({ name }) => name).filter((name) => !released.includes(name))
|
|
12
|
+
if (names.length === 0) return null
|
|
13
|
+
return `${BACKEND_ENVIRONMENT_PREFIX}${backend.command.replaceAll(SERVICES_PLACEHOLDER, names.join(' '))}`
|
|
14
|
+
}
|