jackherd 1.6.0 → 1.7.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 +31 -0
- package/INSTALL.md +176 -0
- package/README.md +260 -315
- package/assets/branding/README.md +7 -3
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -1
- package/assets/icons/bark-dark.svg +1 -0
- package/assets/icons/bark.svg +1 -0
- package/assets/icons/bowl-dark.svg +1 -0
- package/assets/icons/bowl.svg +1 -0
- package/assets/icons/decide-dark.svg +1 -0
- package/assets/icons/decide.svg +1 -0
- package/assets/icons/ear-dark.svg +1 -0
- package/assets/icons/ear.svg +1 -0
- package/assets/icons/lanes-dark.svg +1 -0
- package/assets/icons/lanes.svg +1 -0
- package/assets/icons/plan-dark.svg +1 -0
- package/assets/icons/plan.svg +1 -0
- package/assets/icons/space.svg +1 -0
- package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
- package/dist/config.d.ts +14 -3
- package/dist/config.js +111 -10
- package/dist/plan-format.d.ts +180 -77
- package/dist/plan-format.js +292 -93
- package/docs/lanes-dark.svg +50 -0
- package/docs/lanes.svg +50 -1
- package/docs/skills-dark.svg +65 -0
- package/docs/skills.svg +65 -1
- package/docs/workflow-dark.svg +138 -0
- package/docs/workflow.svg +135 -169
- package/herdr-plugin/herdr-plugin.toml +6 -6
- package/herdr-plugin/open-plans.ts +1 -1
- package/herdr-plugin/restore-plans.ts +7 -7
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
- package/kit/hooks/hooks/approval-gate/index.ts +69 -0
- package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
- package/kit/hooks/hooks/approval-gate/request.ts +111 -0
- package/kit/hooks/hooks/approval-gate/run.ts +94 -0
- package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
- package/kit/hooks/hooks/approval-gate/store.ts +70 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
- package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
- package/kit/hooks/hooks/merge-guard/run.ts +2 -1
- package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
- package/kit/hooks/hooks/model-gate/index.ts +58 -0
- package/kit/hooks/hooks/model-gate/run.ts +63 -0
- package/kit/hooks/hooks/model-log/index.ts +60 -0
- package/kit/hooks/hooks/model-log/run.ts +57 -0
- package/kit/hooks/hooks/model-log/store.ts +40 -0
- package/kit/hooks/hooks/model-log/transcript.ts +37 -0
- package/kit/hooks/hooks/shared/checkout.ts +22 -0
- package/kit/hooks/hooks/shared/command.ts +6 -0
- package/kit/hooks/hooks/shared/config.ts +21 -0
- package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
- package/kit/hooks/hooks/shared/files.ts +19 -0
- package/kit/hooks/hooks/shared/fresh.ts +4 -0
- package/kit/hooks/hooks/shared/json.ts +4 -0
- package/kit/hooks/hooks/shared/model-family.ts +48 -0
- package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
- package/kit/hooks/hooks/shared/private-directory.ts +29 -0
- package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
- package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
- package/kit/hooks/hooks.json +25 -0
- package/kit/workflow/skills/follow/SKILL.md +4 -3
- package/kit/workflow/skills/implement/SKILL.md +6 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
- package/kit/workflow/skills/plan/SKILL.md +5 -5
- package/kit/workflow/skills/start/SKILL.md +2 -2
- package/package.json +4 -1
- package/schemas/jackherd.schema.json +43 -5
- package/src/check/command.ts +31 -4
- package/src/check/deferral-arrows.ts +1 -1
- package/src/check/parallel-report.ts +23 -0
- package/src/check/plan-structure-check.ts +120 -53
- package/src/cli.ts +8 -2
- package/src/config/nested-quantifier.ts +73 -0
- package/src/config/repository-directory.ts +11 -0
- package/src/config/schema.ts +63 -8
- package/src/plan-format/board-prose.ts +36 -0
- package/src/plan-format/dependencies.ts +227 -0
- package/src/plan-format/documentation-gate.ts +16 -0
- package/src/plan-format/index.ts +23 -1
- package/src/plan-format/lanes.ts +35 -1
- package/src/plan-format/model-column.ts +52 -0
- package/src/plan-format/phases.ts +16 -1
- package/src/plan-format/plan-structure.ts +10 -28
- package/src/plan-format/progress.ts +7 -29
- package/src/plans/command.ts +3 -3
- package/src/plans/current-phase.ts +71 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/herd-command.ts +4 -0
- package/src/plans/lane-discovery.ts +14 -2
- package/src/plans/lane-tickets.ts +69 -0
- package/src/plans/layout.ts +182 -88
- package/src/plans/live.ts +5 -1
- package/src/plans/phase-models-read.ts +37 -0
- package/src/plans/phase-models.ts +70 -0
- package/src/plans/plan-panes.ts +8 -7
- package/src/plans/pull-requests.ts +78 -4
- package/src/plans/selection.ts +1 -40
- package/src/plans/settings.ts +9 -3
- package/src/plans/shown-with-more.ts +5 -0
- package/src/plans/snapshot.ts +82 -8
- package/src/plans/sprint-html.ts +2 -2
- package/src/plans/supervisor.ts +5 -4
- package/src/plans/symbols.ts +9 -6
- package/src/plans/texts.ts +30 -13
- package/src/plans/view.ts +1 -0
- package/src/sync/herdr-plugin.ts +1 -1
- package/src/sync/next-steps.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/setup-questions.ts +2 -2
- package/src/terminal/text.ts +4 -0
- package/src/terminal/types.ts +1 -1
- package/src/vocabulary/index.ts +39 -0
- package/src/watch/approvals.ts +96 -0
- package/src/watch/command.ts +154 -50
- package/src/watch/failed-tests.ts +52 -0
- package/src/watch/failure-read.ts +56 -0
- package/src/watch/failures.ts +107 -0
- package/src/watch/feedback-item.ts +21 -0
- package/src/watch/feedback-read.ts +2 -1
- package/src/watch/feedback.ts +37 -28
- package/src/watch/gh-process.ts +53 -0
- package/src/watch/gh.ts +10 -20
- package/src/watch/heartbeat-progress.ts +1 -1
- package/src/watch/lane-model.ts +54 -0
- package/src/watch/lane-parallel.ts +46 -0
- package/src/watch/lane-progress.ts +28 -4
- package/src/watch/lane-report.ts +62 -9
- package/src/watch/lane-sources.ts +1 -1
- package/src/watch/no-finding.ts +47 -0
- package/src/watch/plan-badges-read.ts +19 -7
- package/src/watch/plan-badges.ts +59 -7
- package/src/watch/plan-views.ts +5 -5
- package/src/watch/previews.ts +3 -9
- package/src/watch/pull-requests.ts +12 -2
- package/src/watch/recorded-reads.ts +25 -0
- package/src/watch/source-fields.ts +4 -0
- package/src/watch/source-tickets.ts +99 -0
- package/src/watch/sources.ts +62 -15
- package/src/watch/texts.ts +21 -1
- package/src/watch/watch-defaults.ts +8 -0
- package/src/watch/watch-events.ts +64 -4
- package/src/watch/watcher-lock.ts +1 -1
- package/src/watch/workflow-runs.ts +5 -7
- package/docs/lanes.mmd +0 -9
- package/docs/skills.mmd +0 -10
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
+
import { PHASE_ID_PREFIX } from './lanes'
|
|
3
|
+
import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
|
|
4
|
+
import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A row of the dependency table. `after` holds the phases it waits for, `external` the entries that name no phase
|
|
8
|
+
* (`User: Zuruf`) or a range that cannot be expanded; `writes` are the paths of the writes cell (`''` is the whole
|
|
9
|
+
* repository), `writesLike` the phases whose sets the cell takes over (`wie 15 und 16`); `serialMarkers` are the
|
|
10
|
+
* entries of the marker cell that keep two phases apart.
|
|
11
|
+
*/
|
|
12
|
+
export type PhaseDependency = {
|
|
13
|
+
id: string
|
|
14
|
+
after: string[]
|
|
15
|
+
external: string[]
|
|
16
|
+
writes: string[]
|
|
17
|
+
writesLike: string[]
|
|
18
|
+
serialMarkers: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A phase heading (1-based line) the dependency table has no row for. */
|
|
22
|
+
export type PhaseWithoutRow = PhaseHeading
|
|
23
|
+
|
|
24
|
+
type Candidate = { id: string; writes: string[]; serialMarkers: string[] }
|
|
25
|
+
|
|
26
|
+
const ID = '(\\d+[a-z]?)'
|
|
27
|
+
const PHASE_ID = new RegExp(`^\\s*${PHASE_ID_PREFIX}?${ID}\\b`, 'i')
|
|
28
|
+
const PHASE_RANGE = new RegExp(`^${PHASE_ID_PREFIX}?(\\d+)\\s*[–-]\\s*${PHASE_ID_PREFIX}?(\\d+)$`, 'i')
|
|
29
|
+
const BACKTICKED = /`([^`]+)`/g
|
|
30
|
+
const NO_VALUE = /^[—–-]?$/
|
|
31
|
+
const TRAILING_NOTE = /\s*\([^)]*\)\s*$/
|
|
32
|
+
const GLOB_CHARACTER = /[*?{[]/
|
|
33
|
+
const MAX_RANGE = 50
|
|
34
|
+
|
|
35
|
+
const phaseIdOf = (cell: string): string | null => PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
|
|
36
|
+
|
|
37
|
+
const splitOutsideParentheses = (cell: string, separator: string): string[] => {
|
|
38
|
+
const entries: string[] = []
|
|
39
|
+
let depth = 0
|
|
40
|
+
let start = 0
|
|
41
|
+
for (const [index, character] of [...cell].entries()) {
|
|
42
|
+
if (character === '(') depth += 1
|
|
43
|
+
else if (character === ')') depth = Math.max(depth - 1, 0)
|
|
44
|
+
else if (character === separator && depth === 0) {
|
|
45
|
+
entries.push(cell.slice(start, index))
|
|
46
|
+
start = index + 1
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...entries, cell.slice(start)]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The ids a range entry (`P15–P20`) spans; null when the entry is no range, empty when it cannot be expanded. */
|
|
53
|
+
const rangeOf = (entry: string): string[] | null => {
|
|
54
|
+
const match = PHASE_RANGE.exec(entry)
|
|
55
|
+
if (!match) return null
|
|
56
|
+
const [first, last] = [Number(match[1]), Number(match[2])]
|
|
57
|
+
if (first > last || last - first >= MAX_RANGE) return []
|
|
58
|
+
return Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dependsOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, 'after' | 'external'> => {
|
|
62
|
+
const noDependency = PLAN_VOCABULARY[language].noDependency
|
|
63
|
+
const entries = splitOutsideParentheses(cell, ',')
|
|
64
|
+
.map((entry) => entry.trim())
|
|
65
|
+
.filter((entry) => !NO_VALUE.test(entry) && !noDependency.includes(entry.toLowerCase()))
|
|
66
|
+
const after = entries.flatMap((entry) => rangeOf(entry) ?? phaseIdOf(entry) ?? [])
|
|
67
|
+
const external = entries.filter((entry) => {
|
|
68
|
+
const spanned = rangeOf(entry)
|
|
69
|
+
return spanned === null ? phaseIdOf(entry) === null : spanned.length === 0
|
|
70
|
+
})
|
|
71
|
+
return { after, external }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A path as the cell writes it, cut to the directory a glob character starts in (`scripts/*.ts` is `scripts`,
|
|
76
|
+
* `.claude/**` is `.claude`, `*.md` is the root `''`) and without a trailing slash; null for a backticked entry with a
|
|
77
|
+
* space in it, which is a command.
|
|
78
|
+
*/
|
|
79
|
+
const pathOf = (entry: string): string | null => {
|
|
80
|
+
if (/\s/.test(entry)) return null
|
|
81
|
+
const glob = entry.search(GLOB_CHARACTER)
|
|
82
|
+
const literal = glob === -1 ? entry : entry.slice(0, Math.max(entry.lastIndexOf('/', glob), 0))
|
|
83
|
+
const path = literal.replace(/^\.\//, '').replace(/\/+$/, '')
|
|
84
|
+
return path === '.' ? '' : path
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const likePattern = (language: PlanLanguage): { start: RegExp; id: RegExp } => {
|
|
88
|
+
const { writesLike, writesLikeJoiners } = PLAN_VOCABULARY[language]
|
|
89
|
+
const words = (list: readonly string[]) => list.map(escapeRegExp).join('|')
|
|
90
|
+
const target = `${PHASE_ID_PREFIX}?${ID}`
|
|
91
|
+
const joiner = `(?:\\s*,\\s*|\\s+(?:${words(writesLikeJoiners)})\\s+|\\s*,\\s*(?:${words(writesLikeJoiners)})\\s+)`
|
|
92
|
+
return {
|
|
93
|
+
start: new RegExp(`(?<![\\p{L}\\d])(?:${words(writesLike)})\\s+${target}\\b(?:${joiner}${target}\\b)*`, 'iu'),
|
|
94
|
+
id: new RegExp(`${PHASE_ID_PREFIX}?${ID}\\b`, 'gi'),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The phases a writes cell takes over (`wie 15`, `wie P15 und P16`, `like 15, 16`), found anywhere in the cell. */
|
|
99
|
+
const writesLikeOf = (cell: string, language: PlanLanguage): string[] => {
|
|
100
|
+
const { start, id } = likePattern(language)
|
|
101
|
+
const found = start.exec(cell)?.[0]
|
|
102
|
+
if (found === undefined) return []
|
|
103
|
+
const afterWord = found.replace(/^\S+\s+/, '')
|
|
104
|
+
return [...afterWord.matchAll(id)].map((match) => (match[1] ?? '').toLowerCase())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const writesOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, 'writes' | 'writesLike'> => ({
|
|
108
|
+
writes: [...cell.matchAll(BACKTICKED)].flatMap((match) => pathOf(match[1] ?? '') ?? []),
|
|
109
|
+
writesLike: writesLikeOf(cell, language),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The marker cell's entries, split at `;`, without a trailing note in parentheses and without the release entry: the
|
|
114
|
+
* release word followed by a space or the end, in any case (`Release-Pipeline` is a marker of its own).
|
|
115
|
+
*/
|
|
116
|
+
const markersOf = (cell: string, language: PlanLanguage): string[] => {
|
|
117
|
+
const release = new RegExp(`^${escapeRegExp(PLAN_VOCABULARY[language].releaseMarker)}(?:\\s|$)`, 'i')
|
|
118
|
+
return cell
|
|
119
|
+
.split(';')
|
|
120
|
+
.map((entry) => entry.replace(TRAILING_NOTE, '').trim())
|
|
121
|
+
.filter((entry) => !NO_VALUE.test(entry) && !release.test(entry))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The rows of the plan's dependency table (`### Abhängigkeiten und Parallelisierung`); null when the plan has no
|
|
126
|
+
* such table or the table lacks one of its three columns, so a plan that never had one is not asked for rows.
|
|
127
|
+
*/
|
|
128
|
+
export const parseDependencyTable = (markdown: string, language: PlanLanguage): PhaseDependency[] | null => {
|
|
129
|
+
const { sections, columns } = PLAN_VOCABULARY[language]
|
|
130
|
+
const { header, rows } = firstTableWithHeader(subsectionLines(markdown, sections.dependencies))
|
|
131
|
+
const [after, writes, marker] = [columns.dependsOn, columns.writes, columns.serialMarker].map((name) =>
|
|
132
|
+
header.indexOf(name),
|
|
133
|
+
)
|
|
134
|
+
if (after === -1 || writes === -1 || marker === -1) return null
|
|
135
|
+
return rows.flatMap((cells) => {
|
|
136
|
+
const id = phaseIdOf(cells[0] ?? '')
|
|
137
|
+
if (id === null) return []
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
id,
|
|
141
|
+
...dependsOf(cells[after] ?? '', language),
|
|
142
|
+
...writesOf(cells[writes] ?? '', language),
|
|
143
|
+
serialMarkers: markersOf(cells[marker] ?? '', language),
|
|
144
|
+
},
|
|
145
|
+
]
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The phase headings of a plan with a dependency table that the table has no row for; null for a plan without one. */
|
|
150
|
+
export const phasesWithoutDependencyRow = (markdown: string, language: PlanLanguage): PhaseWithoutRow[] | null => {
|
|
151
|
+
const table = parseDependencyTable(markdown, language)
|
|
152
|
+
if (table === null) return null
|
|
153
|
+
const listed = new Set(table.map(({ id }) => id))
|
|
154
|
+
return phaseHeadings(markdown, language).filter(({ id }) => !listed.has(id))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const mergeSets = (sets: readonly (string[] | null)[]): string[] | null =>
|
|
158
|
+
sets.some((set) => set === null) ? null : sets.flatMap((set) => set ?? [])
|
|
159
|
+
|
|
160
|
+
/** The paths a phase writes, those of every phase its `like` cell names included; null for an unknown target or a cycle. */
|
|
161
|
+
const writeSetOf = (
|
|
162
|
+
row: PhaseDependency,
|
|
163
|
+
byId: ReadonlyMap<string, PhaseDependency>,
|
|
164
|
+
chain: readonly string[],
|
|
165
|
+
): string[] | null => {
|
|
166
|
+
if (chain.includes(row.id)) return null
|
|
167
|
+
const targets = row.writesLike.map((id) => byId.get(id))
|
|
168
|
+
const like = mergeSets(targets.map((target) => (target ? writeSetOf(target, byId, [...chain, row.id]) : null)))
|
|
169
|
+
return like === null ? null : [...row.writes, ...like]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const overlaps = (left: string, right: string): boolean =>
|
|
173
|
+
left === '' || right === '' || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
|
|
174
|
+
|
|
175
|
+
const sharesMarker = (first: Candidate, second: Candidate): boolean => {
|
|
176
|
+
const markers = new Set(second.serialMarkers.map((marker) => marker.trim().toLowerCase()))
|
|
177
|
+
return first.serialMarkers.some((marker) => markers.has(marker.trim().toLowerCase()))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const collides = (first: Candidate, second: Candidate): boolean =>
|
|
181
|
+
sharesMarker(first, second) || first.writes.some((path) => second.writes.some((other) => overlaps(path, other)))
|
|
182
|
+
|
|
183
|
+
const candidateOf = (row: PhaseDependency, byId: ReadonlyMap<string, PhaseDependency>): Candidate[] => {
|
|
184
|
+
const writes = writeSetOf(row, byId, [])
|
|
185
|
+
return writes === null || writes.length === 0 ? [] : [{ id: row.id, writes, serialMarkers: row.serialMarkers }]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The phases that can run side by side, as groups in table order: the first group can start now, a later one holds
|
|
190
|
+
* the ready phases that collide with an earlier group's members. A phase is offered when it is not in `doneIds`,
|
|
191
|
+
* every phase in its `Depends on` cell is, and the cell names no external gate: nothing in the plan says such a gate
|
|
192
|
+
* holds, so the phase stays out and the caller asks. Two phases share a group only when no path of one lies at or
|
|
193
|
+
* below a path of the other (a root path lies below nothing and above everything) and they share no serial marker,
|
|
194
|
+
* compared without case (a release marker does not count). A `wie N` writes cell takes the set of phase N; a phase
|
|
195
|
+
* whose set is empty, whose `wie` target is unknown, or whose `wie` chain is a cycle, cannot be shown disjoint and
|
|
196
|
+
* stays out. Whether two phases collide in content, or in the release files, is not decided here.
|
|
197
|
+
*/
|
|
198
|
+
export const parallelGroups = (rows: readonly PhaseDependency[], doneIds: ReadonlySet<string>): string[][] => {
|
|
199
|
+
const byId = new Map(rows.map((row) => [row.id, row]))
|
|
200
|
+
const ready = rows
|
|
201
|
+
.filter((row) => !doneIds.has(row.id) && row.external.length === 0 && row.after.every((id) => doneIds.has(id)))
|
|
202
|
+
.flatMap((row) => candidateOf(row, byId))
|
|
203
|
+
const groups: Candidate[][] = []
|
|
204
|
+
for (const candidate of ready) {
|
|
205
|
+
const group = groups.find((members) => members.every((member) => !collides(member, candidate)))
|
|
206
|
+
if (group) group.push(candidate)
|
|
207
|
+
else groups.push([candidate])
|
|
208
|
+
}
|
|
209
|
+
return groups.map((group) => group.map(({ id }) => id))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The phases as a report names them, side by side: `P19 ∥ P20`. */
|
|
213
|
+
export const phasesText = (phases: readonly string[]): string =>
|
|
214
|
+
phases.map((id) => `${PHASE_ID_PREFIX}${id}`).join(' ∥ ')
|
|
215
|
+
|
|
216
|
+
const donePhaseIds = (markdown: string, language: PlanLanguage): Set<string> =>
|
|
217
|
+
new Set(
|
|
218
|
+
parsePlanPhases(markdown, language)
|
|
219
|
+
.filter(({ items }) => items.length > 0 && items.every((item) => item.done))
|
|
220
|
+
.map(({ id }) => id),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
/** `parallelGroups` of the plan's own dependency table and ticks (a phase is done when every box is ticked); null for a plan without a table. */
|
|
224
|
+
export const parallelGroupsOfPlan = (markdown: string, language: PlanLanguage): string[][] | null => {
|
|
225
|
+
const table = parseDependencyTable(markdown, language)
|
|
226
|
+
return table === null ? null : parallelGroups(table, donePhaseIds(markdown, language))
|
|
227
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { PLAN_VOCABULARY } from '../vocabulary'
|
|
2
|
+
import type { ChecklistItem } from './phases'
|
|
3
|
+
|
|
4
|
+
const DOCUMENTATION_WORDS = Object.values(PLAN_VOCABULARY).map((vocabulary) =>
|
|
5
|
+
vocabulary.sections.documentation.toLowerCase(),
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
/** The review-gate box `Dokumentation aktualisiert …` (`Documentation updated …`): a gate item starting with the documentation word. */
|
|
9
|
+
export const isDocumentationGateItem = ({ gate, text }: ChecklistItem): boolean =>
|
|
10
|
+
gate === true && DOCUMENTATION_WORDS.some((word) => text.toLowerCase().startsWith(word))
|
|
11
|
+
|
|
12
|
+
/** The documentation gate box is the only unticked box of the phase: everything else is done. */
|
|
13
|
+
export const onlyDocumentationOpen = ({ items }: { items: readonly ChecklistItem[] }): boolean => {
|
|
14
|
+
const open = items.filter((item) => !item.done)
|
|
15
|
+
return open.length === 1 && isDocumentationGateItem(open[0])
|
|
16
|
+
}
|
package/src/plan-format/index.ts
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
export * from '../vocabulary'
|
|
2
|
+
export * from './board-prose'
|
|
3
|
+
export {
|
|
4
|
+
type PhaseDependency,
|
|
5
|
+
type PhaseWithoutRow,
|
|
6
|
+
parallelGroups,
|
|
7
|
+
parallelGroupsOfPlan,
|
|
8
|
+
parseDependencyTable,
|
|
9
|
+
phasesText,
|
|
10
|
+
phasesWithoutDependencyRow,
|
|
11
|
+
} from './dependencies'
|
|
12
|
+
export * from './documentation-gate'
|
|
2
13
|
export * from './drift'
|
|
3
14
|
export * from './header'
|
|
4
|
-
export
|
|
15
|
+
export {
|
|
16
|
+
laneNameOf,
|
|
17
|
+
PHASE_ID_PREFIX,
|
|
18
|
+
type PlanStep,
|
|
19
|
+
parseSprintLanes,
|
|
20
|
+
phaseIdsOfCell,
|
|
21
|
+
resolvePlanPath,
|
|
22
|
+
type SprintLane,
|
|
23
|
+
sprintTitleOf,
|
|
24
|
+
withoutRepeats,
|
|
25
|
+
} from './lanes'
|
|
26
|
+
export * from './model-column'
|
|
5
27
|
export * from './phase-tally'
|
|
6
28
|
export * from './phases'
|
|
7
29
|
export * from './plan-structure'
|
package/src/plan-format/lanes.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { posix } from 'node:path'
|
|
2
2
|
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
-
import { firstHeading, firstTable, sectionLines } from './markdown'
|
|
3
|
+
import { firstHeading, firstTable, firstTableRows, sectionLines, sectionStart } from './markdown'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* `namedPhaseIds` holds every id the row names when `withoutRepeats` dropped some because an earlier row of the lane
|
|
@@ -20,12 +20,16 @@ export type PlanStep = {
|
|
|
20
20
|
/** A lane of the sprint table; `closed` when its name cell says the lane was cleaned up. */
|
|
21
21
|
export type SprintLane = { name: string; port: number | null; steps: PlanStep[]; closed: boolean }
|
|
22
22
|
|
|
23
|
+
/** A row of the lanes table (1-based line) of an open lane with the text of its phases cell. */
|
|
24
|
+
export type OpenLaneRow = { line: number; lane: string; phases: string }
|
|
25
|
+
|
|
23
26
|
const LANE_NAME = /`([^`]+)`/
|
|
24
27
|
const CONTINUATION = '↳'
|
|
25
28
|
const PLAN_LINK = /\[([^\]]+)\]\(([^)]+)\)/
|
|
26
29
|
/** Sprint tables name a phase `P4`; the sidebar title prints it the same way. */
|
|
27
30
|
export const PHASE_ID_PREFIX = 'P'
|
|
28
31
|
|
|
32
|
+
const PHASES_COLUMN = 3
|
|
29
33
|
const CLAUSE_SEPARATOR = /([;,]|\.(?=\s))\s*/
|
|
30
34
|
const ENTRY_END = new Set([';', '.'])
|
|
31
35
|
|
|
@@ -126,3 +130,33 @@ export const sprintTitleOf = (markdown: string, fallback: string): string => fir
|
|
|
126
130
|
/** The repository path of a plan link, relative to the sprint document's directory. */
|
|
127
131
|
export const resolvePlanPath = (sprintDirectory: string, href: string): string =>
|
|
128
132
|
posix.normalize(posix.join(sprintDirectory, href.split('#')[0]))
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The rows of the lanes table (`## Spuren`) that are not in `previousLines` word for word. The table is read as
|
|
136
|
+
* `parseSprintLanes` reads it (its first table, a lane closed when any of its rows says so); rows of a closed lane
|
|
137
|
+
* are history and stay out.
|
|
138
|
+
*/
|
|
139
|
+
export const changedOpenLaneRows = (
|
|
140
|
+
markdown: string,
|
|
141
|
+
language: PlanLanguage,
|
|
142
|
+
previousLines: ReadonlySet<string>,
|
|
143
|
+
): OpenLaneRow[] => {
|
|
144
|
+
const start = sectionStart(markdown, PLAN_VOCABULARY[language].sections.lanes)
|
|
145
|
+
if (start === -1) return []
|
|
146
|
+
const closed = new Set(
|
|
147
|
+
parseSprintLanes(markdown, language)
|
|
148
|
+
.filter((lane) => lane.closed)
|
|
149
|
+
.map((lane) => lane.name),
|
|
150
|
+
)
|
|
151
|
+
const rows: OpenLaneRow[] = []
|
|
152
|
+
let previous: string | null = null
|
|
153
|
+
for (const { index, line, cells } of firstTableRows(
|
|
154
|
+
sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes),
|
|
155
|
+
)) {
|
|
156
|
+
const lane = laneNameOf(cells[0] ?? '', previous)
|
|
157
|
+
previous = lane ?? previous
|
|
158
|
+
if (lane !== null && !closed.has(lane) && !previousLines.has(line))
|
|
159
|
+
rows.push({ line: start + index + 1, lane, phases: cells[PHASES_COLUMN] ?? '' })
|
|
160
|
+
}
|
|
161
|
+
return rows
|
|
162
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A model as a board's model column names it: its words (`Opus 5.5` names `opus`; `claude-sonnet-5-5` names
|
|
3
|
+
* `claude` and `sonnet`, one of which is the family) and its version (`['5', '5']`).
|
|
4
|
+
*/
|
|
5
|
+
export type ColumnModel = { names: string[]; version: string[] }
|
|
6
|
+
|
|
7
|
+
const NOTE = /\([^)]*\)/g
|
|
8
|
+
const ENTRY_SEPARATOR = /[/,+→]/
|
|
9
|
+
const TOKEN_SEPARATOR = /[^a-z0-9]+/
|
|
10
|
+
const NAME = /^[a-z]+$/
|
|
11
|
+
const NUMBER = /^\d+$/
|
|
12
|
+
|
|
13
|
+
const tokensOf = (text: string): string[] => text.toLowerCase().split(TOKEN_SEPARATOR)
|
|
14
|
+
|
|
15
|
+
/** Words and numbers of an entry; a mixed token (`1m`, a window mark) is neither. */
|
|
16
|
+
const modelOf = (entry: string): ColumnModel | null => {
|
|
17
|
+
const tokens = tokensOf(entry)
|
|
18
|
+
const names = tokens.filter((token) => NAME.test(token))
|
|
19
|
+
return names.length > 0 ? { names, version: tokens.filter((token) => NUMBER.test(token)) } : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The models a model column names, in its order; a note in brackets (`(Plan)`) and a dash name none. */
|
|
23
|
+
export const columnModels = (column: string): ColumnModel[] =>
|
|
24
|
+
column
|
|
25
|
+
.replace(NOTE, '')
|
|
26
|
+
.split(ENTRY_SEPARATOR)
|
|
27
|
+
.flatMap((entry) => modelOf(entry) ?? [])
|
|
28
|
+
|
|
29
|
+
const startsWith = (tokens: readonly string[], prefix: readonly string[]): boolean =>
|
|
30
|
+
prefix.every((part, index) => tokens[index] === part)
|
|
31
|
+
|
|
32
|
+
/** Whether the id has the name as a whole word with the version right after it or right before it. */
|
|
33
|
+
const fitsName = (tokens: readonly string[], name: string, version: readonly string[]): boolean =>
|
|
34
|
+
tokens.some(
|
|
35
|
+
(token, index) =>
|
|
36
|
+
token === name &&
|
|
37
|
+
(startsWith(tokens.slice(index + 1), version) ||
|
|
38
|
+
startsWith(tokens.slice(index - version.length, index), version)),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const fits = (tokens: readonly string[], { names, version }: ColumnModel): boolean =>
|
|
42
|
+
names.some((name) => fitsName(tokens, name, version))
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether a running model's id fits none of the models the column names, by family and version (`Opus 5.5` fits
|
|
46
|
+
* `claude-opus-5-5`, also with a date or window suffix). Knows no vendor. A column that names no model never differs.
|
|
47
|
+
*/
|
|
48
|
+
export const differsFromColumn = (column: string, modelId: string): boolean => {
|
|
49
|
+
const models = columnModels(column)
|
|
50
|
+
const tokens = tokensOf(modelId)
|
|
51
|
+
return models.length > 0 && !models.some((model) => fits(tokens, model))
|
|
52
|
+
}
|
|
@@ -18,6 +18,9 @@ export type MisplacedDeferral = { line: number; target: string; corrected: strin
|
|
|
18
18
|
*/
|
|
19
19
|
export type PlanPhase = { id: string; title: string; items: ChecklistItem[]; deferred?: DeferredItem[]; touched?: true }
|
|
20
20
|
|
|
21
|
+
/** A phase heading with its 1-based line, the lower-case id and the title. */
|
|
22
|
+
export type PhaseHeading = { line: number; id: string; title: string }
|
|
23
|
+
|
|
21
24
|
const REVIEW_GATE = /^\s*\*\*review[- ]gate\b/i
|
|
22
25
|
const PHASE_END = /^#{1,3}\s/
|
|
23
26
|
const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/
|
|
@@ -25,7 +28,8 @@ const SECTION_HEADING = /^##\s+(.+?)\s*$/
|
|
|
25
28
|
const TOP_HEADING = /^#\s/
|
|
26
29
|
const TABLE_STATUS = { done: '✅', open: '⬜' } as const
|
|
27
30
|
const ROW_NUMBER = /^\d+(\.\d+)*[a-z]?$/
|
|
28
|
-
|
|
31
|
+
/** `## 10 — title`, and `## P15 — title` as a board names the phase. */
|
|
32
|
+
const NUMBERED_SECTION = new RegExp(`^##\\s+${PHASE_ID_PREFIX}?(\\d+[a-z]?)\\s*[—–:-]\\s*(.*)$`)
|
|
29
33
|
const SECTION_POINTER = /`##\s+([^`]+?)\s*`/
|
|
30
34
|
const DEFERRED_TO = new RegExp(`\\s→\\s*${PHASE_ID_PREFIX}(\\d+[a-z]?)\\s*$`, 'i')
|
|
31
35
|
/** A phase arrow anywhere in a line, with the whitespace before it. */
|
|
@@ -195,6 +199,17 @@ const notePointer = (pointers: Map<PlanPhase, string>, phase: PlanPhase | null,
|
|
|
195
199
|
const headingOf = (line: string, phaseHeading: RegExp): RegExpMatchArray | null =>
|
|
196
200
|
line.match(phaseHeading) ?? line.match(NUMBERED_SECTION)
|
|
197
201
|
|
|
202
|
+
/** Every phase heading of a plan, `### Phase <id>` and `## P<id> — title` alike, with its 1-based line. */
|
|
203
|
+
export const phaseHeadings = (markdown: string, language: PlanLanguage): PhaseHeading[] => {
|
|
204
|
+
const phaseHeading = phaseHeadingPattern(language)
|
|
205
|
+
return markdown.split('\n').flatMap((text, index) => {
|
|
206
|
+
const heading = headingOf(text, phaseHeading)
|
|
207
|
+
return heading
|
|
208
|
+
? [{ line: index + 1, id: (heading[1] ?? '').toLowerCase(), title: terminalSafe(heading[2] ?? '').trim() }]
|
|
209
|
+
: []
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
198
213
|
/**
|
|
199
214
|
* Every `### Phase <id>` section, and every `## <id> — title` section, with its checkboxes. A
|
|
200
215
|
* section runs to the next heading of level three or higher, so a review-gate list or a `####`
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
-
import {
|
|
3
|
-
import { escapeRegExp,
|
|
2
|
+
import { changedOpenLaneRows, type OpenLaneRow, phaseIdsOfCell } from './lanes'
|
|
3
|
+
import { escapeRegExp, sectionLines } from './markdown'
|
|
4
4
|
import {
|
|
5
5
|
deferralTarget,
|
|
6
6
|
endsPhase,
|
|
7
7
|
isCheckboxLine,
|
|
8
8
|
isOpenCheckboxLine,
|
|
9
9
|
isReviewGateLine,
|
|
10
|
+
type PhaseHeading,
|
|
10
11
|
type PlanPhase,
|
|
11
12
|
parsePlanPhases,
|
|
12
13
|
phaseHeadingPattern,
|
|
@@ -14,10 +15,10 @@ import {
|
|
|
14
15
|
import { terminalSafe } from './terminal-text'
|
|
15
16
|
|
|
16
17
|
/** A `### Phase` heading (1-based line) whose section has tasks and neither a review gate nor the line saying why not. */
|
|
17
|
-
export type PhaseWithoutGate =
|
|
18
|
+
export type PhaseWithoutGate = PhaseHeading
|
|
18
19
|
|
|
19
20
|
/** A row of the lanes table (1-based line) whose phases cell names no phase id. */
|
|
20
|
-
export type RowWithoutPhase =
|
|
21
|
+
export type RowWithoutPhase = OpenLaneRow
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* A `### Phase` heading (1-based line) whose tasks are all ticked on the base branch while its own section still
|
|
@@ -118,33 +119,14 @@ export const mergedPhasesWithOpenBoxes = (
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
/**
|
|
121
|
-
* The rows of
|
|
122
|
-
*
|
|
123
|
-
* as `parseSprintLanes` reads it (its first table, a lane closed when any of its rows says so); rows of a closed
|
|
124
|
-
* lane are history and stay out.
|
|
122
|
+
* The changed rows of open lanes whose phases cell names no phase id (`P10c`), so neither the view nor the watcher
|
|
123
|
+
* can tie that step to a phase.
|
|
125
124
|
*/
|
|
126
125
|
export const boardRowsWithoutPhase = (
|
|
127
126
|
markdown: string,
|
|
128
127
|
language: PlanLanguage,
|
|
129
128
|
previousLines: ReadonlySet<string>,
|
|
130
|
-
): RowWithoutPhase[] =>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const closed = new Set(
|
|
134
|
-
parseSprintLanes(markdown, language)
|
|
135
|
-
.filter((lane) => lane.closed)
|
|
136
|
-
.map((lane) => lane.name),
|
|
129
|
+
): RowWithoutPhase[] =>
|
|
130
|
+
changedOpenLaneRows(markdown, language, previousLines).filter(
|
|
131
|
+
({ phases }) => phaseIdsOfCell(phases, language).length === 0,
|
|
137
132
|
)
|
|
138
|
-
const rows: RowWithoutPhase[] = []
|
|
139
|
-
let previous: string | null = null
|
|
140
|
-
for (const { index, line, cells } of firstTableRows(
|
|
141
|
-
sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes),
|
|
142
|
-
)) {
|
|
143
|
-
const [name = '', , , phases = ''] = cells
|
|
144
|
-
const lane = laneNameOf(name, previous)
|
|
145
|
-
previous = lane ?? previous
|
|
146
|
-
const counts = lane !== null && !closed.has(lane) && !previousLines.has(line)
|
|
147
|
-
if (counts && phaseIdsOfCell(phases, language).length === 0) rows.push({ line: start + index + 1, lane, phases })
|
|
148
|
-
}
|
|
149
|
-
return rows
|
|
150
|
-
}
|
|
@@ -33,30 +33,12 @@ export const sumPhases = (phases: readonly PhaseProgress[]): { done: number; tot
|
|
|
33
33
|
total: phases.reduce((sum, phase) => sum + phase.total, 0),
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
phases.find((phase) => phase.id === id) ?? phases.find((phase) => phase.id === baseId(id))
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The ids of a step that show a phase of their own. An id the plan lacks (`5b`) falls back to its
|
|
43
|
-
* base phase (`5`); when the step already names that phase by its own id, or an earlier id fell
|
|
44
|
-
* back to it, the fallback would list the same tasks twice, so it is dropped. A lone fallback
|
|
45
|
-
* stays: the sprint line `P4a` finds phase 4 in the plans that have no `4a`.
|
|
46
|
-
*/
|
|
47
|
-
export const keptPhaseIds = (phases: PlanPhase[], ids: readonly string[]): string[] => {
|
|
48
|
-
const shown = new Set(ids.flatMap((id) => phases.filter((phase) => phase.id === id)))
|
|
49
|
-
return ids.filter((id) => {
|
|
50
|
-
const phase = findPhase(phases, id)
|
|
51
|
-
if (!phase || phase.id === id) return true
|
|
52
|
-
if (shown.has(phase)) return false
|
|
53
|
-
shown.add(phase)
|
|
54
|
-
return true
|
|
55
|
-
})
|
|
56
|
-
}
|
|
36
|
+
/** The phase with exactly this id: a board row's `3c` never falls back to phase 3. */
|
|
37
|
+
const phaseById = <T extends { id: string }>(phases: readonly T[], id: string): T | undefined =>
|
|
38
|
+
phases.find((phase) => phase.id === id)
|
|
57
39
|
|
|
58
40
|
const phaseProgress = (phases: PlanPhase[], id: string): PhaseProgress => {
|
|
59
|
-
const phase =
|
|
41
|
+
const phase = phaseById(phases, id)
|
|
60
42
|
const items = phase?.items ?? []
|
|
61
43
|
return {
|
|
62
44
|
id,
|
|
@@ -102,16 +84,12 @@ const newestStepWith = (
|
|
|
102
84
|
|
|
103
85
|
type LocatedPhase = { phase: PhaseProgress; current: NonNullable<LaneProgress['current']> }
|
|
104
86
|
|
|
105
|
-
/** The phase a step shows for `id`: by that id, else by its base id, as `keptPhaseIds` keeps a lone fallback. */
|
|
106
|
-
const shownPhase = (phases: readonly PhaseProgress[], id: string): PhaseProgress | undefined =>
|
|
107
|
-
phases.find((phase) => phase.id === id) ?? phases.find((phase) => phase.id === baseId(id))
|
|
108
|
-
|
|
109
87
|
/** The phase `id` as the newest step before `before` that shows it from the same plan has it. */
|
|
110
88
|
const earlierPhase = (steps: readonly StepProgress[], before: number, id: string): LocatedPhase | null => {
|
|
111
89
|
const planHref = steps[before].step.planHref
|
|
112
90
|
for (let stepIndex = before - 1; stepIndex >= 0; stepIndex--) {
|
|
113
91
|
if (steps[stepIndex].step.planHref !== planHref) continue
|
|
114
|
-
const phase =
|
|
92
|
+
const phase = phaseById(steps[stepIndex].phases, id)
|
|
115
93
|
if (phase) return { phase, current: { stepIndex, phaseId: phase.id } }
|
|
116
94
|
}
|
|
117
95
|
return null
|
|
@@ -179,7 +157,7 @@ export const currentPhaseOf = (steps: readonly StepProgress[]): LaneProgress['cu
|
|
|
179
157
|
export const laneProgress = (lane: SprintLane, plans: PlansByHref): LaneProgress => {
|
|
180
158
|
const steps = lane.steps.map((step) => {
|
|
181
159
|
const phases = step.planHref ? plans[step.planHref] : undefined
|
|
182
|
-
return { step, phases: phases ?
|
|
160
|
+
return { step, phases: phases ? step.phaseIds.map((id) => phaseProgress(phases, id)) : [] }
|
|
183
161
|
})
|
|
184
162
|
return { lane, steps, current: currentPhaseOf(steps), ...sumPhases(steps.flatMap((step) => step.phases)) }
|
|
185
163
|
}
|
|
@@ -187,7 +165,7 @@ export const laneProgress = (lane: SprintLane, plans: PlansByHref): LaneProgress
|
|
|
187
165
|
export const currentPhase = (progress: LaneProgress): PhaseProgress | null => {
|
|
188
166
|
if (!progress.current) return null
|
|
189
167
|
const { stepIndex, phaseId } = progress.current
|
|
190
|
-
return progress.steps[stepIndex].phases
|
|
168
|
+
return phaseById(progress.steps[stepIndex].phases, phaseId) ?? null
|
|
191
169
|
}
|
|
192
170
|
|
|
193
171
|
/** The item being worked on: the first open checkbox of a phase in plan order, a review-gate item included; null when none is open. */
|
package/src/plans/command.ts
CHANGED
|
@@ -27,8 +27,8 @@ const printOnce = async (source: Source): Promise<void> => {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* `jh
|
|
31
|
-
* progress; `--ensure` instead brings back the missing
|
|
30
|
+
* `jh herd [<board.md>] [--lane <name>] [--once | --ensure]`: a live view of a board's lanes and their plan
|
|
31
|
+
* progress; `--ensure` instead brings back the missing herd panes beside the coordinator and each lane.
|
|
32
32
|
* `home` is where the global config is looked up when the project has none.
|
|
33
33
|
*/
|
|
34
34
|
export const run = async (args: string[], home: string = homedir()): Promise<number> => {
|
|
@@ -45,7 +45,7 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
45
45
|
settings.adapter === TERMINAL_ADAPTER.herdr
|
|
46
46
|
? ensurePlanPanes(root, settings, picked.file)
|
|
47
47
|
: ['the terminal adapter is not herdr: no panes to bring back']
|
|
48
|
-
for (const line of lines.length > 0 ? lines : ['every
|
|
48
|
+
for (const line of lines.length > 0 ? lines : ['every herd pane is running']) console.log(line)
|
|
49
49
|
return 0
|
|
50
50
|
}
|
|
51
51
|
const source = { root, sprintFile: picked.file, options, settings, terminal, boardNotice: picked.notice }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isTickedUnmerged, type LaneProgress, type PhaseProgress, type PlansByHref } from '../plan-format'
|
|
2
|
+
|
|
3
|
+
/** A phase of a plan, as the sprint table spells the plan's link. */
|
|
4
|
+
export type PhaseRef = { planHref: string; phaseId: string }
|
|
5
|
+
|
|
6
|
+
/** Per plan link and phase id, the state of its checkboxes as `1` (ticked) and `0` (open) in plan order. */
|
|
7
|
+
export type TickSnapshot = Record<string, Record<string, string>>
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What the herd remembers of a lane between two reads: the ticks it saw, the phase whose ticks moved last and the
|
|
11
|
+
* identity of the newest board row (`newestRowIdentity`), which a new row changes.
|
|
12
|
+
*/
|
|
13
|
+
export type LaneMemory = { ticks: TickSnapshot; moved: PhaseRef | null; row: string }
|
|
14
|
+
|
|
15
|
+
export const tickSnapshotOf = (plans: PlansByHref): TickSnapshot =>
|
|
16
|
+
Object.fromEntries(
|
|
17
|
+
Object.entries(plans).map(([href, phases]) => [
|
|
18
|
+
href,
|
|
19
|
+
Object.fromEntries(
|
|
20
|
+
phases.map((phase) => [phase.id, phase.items.map((item) => (item.done ? '1' : '0')).join('')]),
|
|
21
|
+
),
|
|
22
|
+
]),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The phases both reads have whose ticks differ, in plan order; a plan or phase new in `next` has no state to move
|
|
27
|
+
* from. A task added to or removed from a phase changes its tick string and counts as movement.
|
|
28
|
+
*/
|
|
29
|
+
export const movedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
|
|
30
|
+
Object.entries(next).flatMap(([planHref, phases]) =>
|
|
31
|
+
Object.entries(phases).flatMap(([phaseId, ticks]) => {
|
|
32
|
+
const before = previous[planHref]?.[phaseId]
|
|
33
|
+
return before !== undefined && before !== ticks ? [{ planHref, phaseId }] : []
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** The newest board row of a lane as its step index and the phase ids it shows, a step `withWorkedPhases` added not counted. */
|
|
38
|
+
export const newestRowIdentity = ({ steps }: LaneProgress): string => {
|
|
39
|
+
const index = steps.reduce((last, { step }, stepIndex) => (step.withoutRow ? last : stepIndex), -1)
|
|
40
|
+
return `${index}:${(steps[index]?.phases ?? []).map((phase) => phase.id).join(',')}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The memory after a read of the lane's plans. The first read has no earlier ticks and no moved phase; exactly one
|
|
45
|
+
* moved phase becomes the remembered one; none keeps the one moved last; several at once decide nothing, so the
|
|
46
|
+
* newest board row rules again until a single phase moves. A changed newest board row (`row`) is an explicit
|
|
47
|
+
* signal and clears the memory.
|
|
48
|
+
*/
|
|
49
|
+
export const nextMemory = (previous: LaneMemory | undefined, plans: PlansByHref, row: string): LaneMemory => {
|
|
50
|
+
const ticks = tickSnapshotOf(plans)
|
|
51
|
+
if (!previous || previous.row !== row) return { ticks, moved: null, row }
|
|
52
|
+
const moved = movedPhases(previous.ticks, ticks)
|
|
53
|
+
if (moved.length === 0) return { ticks, moved: previous.moved, row }
|
|
54
|
+
return { ticks, moved: moved.length === 1 ? moved[0] : null, row }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The progress with `moved` as the current phase, in the newest step that shows it with checkboxes; unchanged
|
|
59
|
+
* when there is no moved phase, the lane does not show it or it is done and merged, so the newest board row decides
|
|
60
|
+
* as before.
|
|
61
|
+
*/
|
|
62
|
+
export const withMovedPhase = (progress: LaneProgress, moved: PhaseRef | null): LaneProgress => {
|
|
63
|
+
if (!moved) return progress
|
|
64
|
+
const isCurrentWork = (phase: PhaseProgress): boolean =>
|
|
65
|
+
phase.id === moved.phaseId && phase.total > 0 && (phase.done < phase.total || isTickedUnmerged(phase))
|
|
66
|
+
const showsIt = progress.steps.map(
|
|
67
|
+
({ step, phases }) => step.planHref === moved.planHref && phases.some(isCurrentWork),
|
|
68
|
+
)
|
|
69
|
+
const stepIndex = showsIt.lastIndexOf(true)
|
|
70
|
+
return stepIndex < 0 ? progress : { ...progress, current: { stepIndex, phaseId: moved.phaseId } }
|
|
71
|
+
}
|