jackherd 1.7.0 → 1.8.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 +43 -0
- package/INSTALL.md +4 -2
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +94 -1033
- package/assets/icons/bark-dark.svg +1 -1
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl-dark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide-dark.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear-dark.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes-dark.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan-dark.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +73 -7
- package/dist/plan-format.js +180 -100
- package/docs/workflow-dark.svg +1 -1
- package/docs/workflow.svg +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
- package/kit/hooks/hooks/approval-gate/index.ts +10 -8
- package/kit/hooks/hooks/approval-gate/run.ts +21 -12
- package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
- package/kit/hooks/hooks/git-guard/decision.ts +189 -15
- package/kit/hooks/hooks/git-guard/index.ts +13 -3
- package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
- package/kit/hooks/hooks/git-guard/repository.ts +63 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
- package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
- package/kit/hooks/hooks/merge-guard/index.ts +6 -4
- package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
- package/kit/hooks/hooks/merge-guard/run.ts +118 -29
- package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
- package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
- package/kit/hooks/hooks/shared/base-branch.ts +23 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
- package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
- package/kit/hooks/hooks/shared/command.ts +14 -2
- package/kit/hooks/hooks/shared/commit.ts +2 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
- package/kit/hooks/hooks/shared/json.ts +10 -0
- package/kit/hooks/hooks/shared/lane.ts +57 -0
- package/kit/hooks/hooks/shared/once.ts +8 -0
- package/kit/hooks/hooks/shared/own-pane.ts +31 -0
- package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
- package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
- package/kit/hooks/hooks/shared/program-position.ts +148 -5
- package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
- package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
- package/kit/hooks/hooks/shared/shell.ts +167 -20
- package/kit/hooks/hooks.json +11 -2
- package/kit/hooks/statusline.sh +29 -16
- package/kit/review/skills/diagnose/SKILL.md +1 -1
- package/kit/review/skills/review/SKILL.md +2 -0
- package/kit/rules/rules/kit/testing.md +2 -5
- package/kit/rules/rules/kit/writing-for-agents.md +3 -1
- package/kit/workflow/agents/spec-review.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/grill/SKILL.md +1 -1
- package/kit/workflow/skills/implement/SKILL.md +10 -11
- package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
- package/kit/workflow/skills/plan/SKILL.md +5 -2
- package/kit/workflow/skills/plan/references/template-English.md +6 -0
- package/kit/workflow/skills/plan/references/template-German.md +6 -0
- package/package.json +3 -2
- package/recipes/README.md +21 -0
- package/recipes/github-issues.md +23 -0
- package/recipes/github-projects.md +22 -0
- package/recipes/jira-cli.md +26 -0
- package/recipes/jira-rest.md +24 -0
- package/recipes/linear.md +25 -0
- package/recipes/sentry.md +18 -0
- package/recipes/sources/jira.ts +51 -0
- package/recipes/sources/linear.ts +19 -0
- package/recipes/sources/sentry.ts +12 -0
- package/recipes/sources.md +80 -0
- package/recipes/status-page.md +19 -0
- package/schemas/jackherd.schema.json +42 -18
- package/src/check/command.ts +2 -2
- package/src/check/parallel-report.ts +19 -9
- package/src/cli.ts +1 -0
- package/src/config/context-shares.ts +130 -0
- package/src/config/legacy-names.ts +8 -0
- package/src/config/read-config.ts +12 -4
- package/src/config/schema.ts +75 -20
- package/src/context/auto-compact.ts +68 -0
- package/src/context/command.ts +68 -17
- package/src/context/coordinator-name.ts +27 -9
- package/src/context/index.ts +0 -1
- package/src/context/level.ts +18 -11
- package/src/context/report.ts +6 -3
- package/src/plan-format/dependencies.ts +144 -32
- package/src/plan-format/index.ts +6 -0
- package/src/plan-format/lanes.ts +6 -0
- package/src/plan-format/progress.ts +19 -8
- package/src/plans/command.ts +14 -5
- package/src/plans/current-phase.ts +63 -7
- package/src/plans/current-task.ts +51 -0
- package/src/plans/ensure-panes.ts +25 -8
- package/src/plans/frame.ts +99 -0
- package/src/plans/git.ts +8 -0
- package/src/plans/herd-memory.ts +107 -0
- package/src/plans/lane-plans.ts +5 -3
- package/src/plans/lane-summary.ts +222 -0
- package/src/plans/layout-phase.ts +148 -0
- package/src/plans/layout-summary.ts +140 -0
- package/src/plans/layout.ts +164 -420
- package/src/plans/live.ts +11 -6
- package/src/plans/plan-panes.ts +15 -0
- package/src/plans/planned.ts +9 -42
- package/src/plans/selection.ts +2 -6
- package/src/plans/settings.ts +2 -7
- package/src/plans/snapshot.ts +38 -39
- package/src/plans/symbols.ts +2 -2
- package/src/plans/texts.ts +32 -31
- package/src/plans/view.ts +0 -1
- package/src/plans/worked-phases.ts +2 -2
- package/src/settled/board-row.ts +133 -0
- package/src/settled/check.ts +129 -0
- package/src/settled/command.ts +65 -0
- package/src/settled/evidence-version.ts +14 -0
- package/src/settled/options.ts +69 -0
- package/src/settled/send.ts +27 -0
- package/src/settled/settle.ts +87 -0
- package/src/settled/topics.ts +56 -0
- package/src/sync/command.ts +16 -1
- package/src/sync/render.ts +13 -4
- package/src/sync/sync.ts +25 -5
- package/src/terminal/herdr.ts +46 -10
- package/src/terminal/none.ts +1 -0
- package/src/terminal/types.ts +15 -2
- package/src/update/command.ts +4 -2
- package/src/update/update.ts +33 -9
- package/src/vocabulary/index.ts +47 -11
- package/src/watch/appointments.ts +205 -0
- package/src/watch/command.ts +62 -19
- package/src/watch/lane-report.ts +11 -3
- package/src/watch/texts.ts +35 -59
- package/src/watch/watch-events.ts +21 -28
- package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
-
import { PHASE_ID_PREFIX } from './lanes'
|
|
2
|
+
import { PHASE_ID_PREFIX, phaseLabels } from './lanes'
|
|
3
3
|
import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
|
|
4
4
|
import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
|
|
5
|
+
import { isPhaseFinished } from './progress'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* A row of the dependency table. `after` holds the phases it waits for, `external` the entries that name no phase
|
|
8
9
|
* (`User: Zuruf`) or a range that cannot be expanded; `writes` are the paths of the writes cell (`''` is the whole
|
|
9
10
|
* 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
|
+
* entries of the marker cell that keep two phases apart, `releases` the packages its release markers name
|
|
12
|
+
* (`Release jackherd 1.8.0` is `jackherd`).
|
|
11
13
|
*/
|
|
12
14
|
export type PhaseDependency = {
|
|
13
15
|
id: string
|
|
@@ -16,12 +18,16 @@ export type PhaseDependency = {
|
|
|
16
18
|
writes: string[]
|
|
17
19
|
writesLike: string[]
|
|
18
20
|
serialMarkers: string[]
|
|
21
|
+
releases: string[]
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
/** The open phases in waves (each starts once the ones before it are done), the longest chain, and the open phases no wave holds. */
|
|
25
|
+
export type PhaseSchedule = { waves: string[][]; criticalPath: string[]; unplaced: string[] }
|
|
26
|
+
|
|
21
27
|
/** A phase heading (1-based line) the dependency table has no row for. */
|
|
22
28
|
export type PhaseWithoutRow = PhaseHeading
|
|
23
29
|
|
|
24
|
-
type Candidate = { id: string; writes: string[]; serialMarkers: string[] }
|
|
30
|
+
type Candidate = { id: string; writes: string[]; serialMarkers: string[]; releases: string[] }
|
|
25
31
|
|
|
26
32
|
const ID = '(\\d+[a-z]?)'
|
|
27
33
|
const PHASE_ID = new RegExp(`^\\s*${PHASE_ID_PREFIX}?${ID}\\b`, 'i')
|
|
@@ -30,6 +36,10 @@ const BACKTICKED = /`([^`]+)`/g
|
|
|
30
36
|
const NO_VALUE = /^[—–-]?$/
|
|
31
37
|
const TRAILING_NOTE = /\s*\([^)]*\)\s*$/
|
|
32
38
|
const GLOB_CHARACTER = /[*?{[]/
|
|
39
|
+
const VERSION = /^v?\d+(?:[.\-+][\w.-]*)?$/i
|
|
40
|
+
const PACKAGE_SCOPE = /^@[^/]+\//
|
|
41
|
+
/** The files a release of the package in their directory changes: the changelog and the version in the manifest. */
|
|
42
|
+
const RELEASE_FILE = /(?:^|\/)(?:CHANGELOG\.md|package\.json)$/
|
|
33
43
|
const MAX_RANGE = 50
|
|
34
44
|
|
|
35
45
|
const phaseIdOf = (cell: string): string | null => PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
|
|
@@ -109,18 +119,51 @@ const writesOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, '
|
|
|
109
119
|
writesLike: writesLikeOf(cell, language),
|
|
110
120
|
})
|
|
111
121
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const release = new RegExp(`^${escapeRegExp(PLAN_VOCABULARY[language].releaseMarker)}(?:\\s|$)`, 'i')
|
|
118
|
-
return cell
|
|
122
|
+
const releasePattern = (language: PlanLanguage): RegExp =>
|
|
123
|
+
new RegExp(`^${escapeRegExp(PLAN_VOCABULARY[language].releaseMarker)}(?:\\s|$)`, 'i')
|
|
124
|
+
|
|
125
|
+
const markerEntries = (cell: string): string[] =>
|
|
126
|
+
cell
|
|
119
127
|
.split(';')
|
|
120
128
|
.map((entry) => entry.replace(TRAILING_NOTE, '').trim())
|
|
121
|
-
.filter((entry) => !NO_VALUE.test(entry)
|
|
129
|
+
.filter((entry) => !NO_VALUE.test(entry))
|
|
130
|
+
|
|
131
|
+
/** A package as releases compare it: without its scope, in lower case (`@x/UI` and `packages/ui` are both `ui`). */
|
|
132
|
+
const packageKey = (name: string): string => name.replace(PACKAGE_SCOPE, '').toLowerCase()
|
|
133
|
+
|
|
134
|
+
/** The package a release entry names: the words after the release word that are no version (`Release @x/ui 2.0` is `ui`). */
|
|
135
|
+
const releasedPackageOf = (entry: string): string[] => {
|
|
136
|
+
const name = entry
|
|
137
|
+
.split(/\s+/)
|
|
138
|
+
.slice(1)
|
|
139
|
+
.filter((word) => !VERSION.test(word))
|
|
140
|
+
.join(' ')
|
|
141
|
+
return name ? [packageKey(name)] : []
|
|
122
142
|
}
|
|
123
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The marker cell split once into its entries at `;`, each without a trailing note in parentheses: the release
|
|
146
|
+
* entries (the release word followed by a space or the end, in any case; `Release-Pipeline` is a marker of its own)
|
|
147
|
+
* give the packages they name, a release entry with a version alone (`Release 1.7.0`) none; every other entry is a
|
|
148
|
+
* serial marker.
|
|
149
|
+
*/
|
|
150
|
+
const markerCellOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, 'serialMarkers' | 'releases'> => {
|
|
151
|
+
const release = releasePattern(language)
|
|
152
|
+
const entries = markerEntries(cell)
|
|
153
|
+
return {
|
|
154
|
+
serialMarkers: entries.filter((entry) => !release.test(entry)),
|
|
155
|
+
releases: entries.filter((entry) => release.test(entry)).flatMap(releasedPackageOf),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The packages whose release files a write set touches, by the last segment of their directory (`.` for the root). */
|
|
160
|
+
const releasedByFiles = (writes: readonly string[]): string[] =>
|
|
161
|
+
writes.flatMap((path) => {
|
|
162
|
+
if (!RELEASE_FILE.test(path)) return []
|
|
163
|
+
const directory = path.split('/').slice(0, -1)
|
|
164
|
+
return [packageKey(directory.at(-1) ?? '.')]
|
|
165
|
+
})
|
|
166
|
+
|
|
124
167
|
/**
|
|
125
168
|
* The rows of the plan's dependency table (`### Abhängigkeiten und Parallelisierung`); null when the plan has no
|
|
126
169
|
* such table or the table lacks one of its three columns, so a plan that never had one is not asked for rows.
|
|
@@ -140,7 +183,7 @@ export const parseDependencyTable = (markdown: string, language: PlanLanguage):
|
|
|
140
183
|
id,
|
|
141
184
|
...dependsOf(cells[after] ?? '', language),
|
|
142
185
|
...writesOf(cells[writes] ?? '', language),
|
|
143
|
-
|
|
186
|
+
...markerCellOf(cells[marker] ?? '', language),
|
|
144
187
|
},
|
|
145
188
|
]
|
|
146
189
|
})
|
|
@@ -154,6 +197,9 @@ export const phasesWithoutDependencyRow = (markdown: string, language: PlanLangu
|
|
|
154
197
|
return phaseHeadings(markdown, language).filter(({ id }) => !listed.has(id))
|
|
155
198
|
}
|
|
156
199
|
|
|
200
|
+
const longestChain = (chains: readonly string[][]): string[] =>
|
|
201
|
+
chains.reduce<string[]>((longest, chain) => (chain.length > longest.length ? chain : longest), [])
|
|
202
|
+
|
|
157
203
|
const mergeSets = (sets: readonly (string[] | null)[]): string[] | null =>
|
|
158
204
|
sets.some((set) => set === null) ? null : sets.flatMap((set) => set ?? [])
|
|
159
205
|
|
|
@@ -172,30 +218,26 @@ const writeSetOf = (
|
|
|
172
218
|
const overlaps = (left: string, right: string): boolean =>
|
|
173
219
|
left === '' || right === '' || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
|
|
174
220
|
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
return first.
|
|
221
|
+
const sharesEntry = (first: readonly string[], second: readonly string[]): boolean => {
|
|
222
|
+
const entries = new Set(second.map((entry) => entry.trim().toLowerCase()))
|
|
223
|
+
return first.some((entry) => entries.has(entry.trim().toLowerCase()))
|
|
178
224
|
}
|
|
179
225
|
|
|
226
|
+
/** A shared serial marker, or a release of the same package: two releases of one package never run side by side. */
|
|
227
|
+
const sharesMarker = (first: Candidate, second: Candidate): boolean =>
|
|
228
|
+
sharesEntry(first.serialMarkers, second.serialMarkers) || sharesEntry(first.releases, second.releases)
|
|
229
|
+
|
|
180
230
|
const collides = (first: Candidate, second: Candidate): boolean =>
|
|
181
231
|
sharesMarker(first, second) || first.writes.some((path) => second.writes.some((other) => overlaps(path, other)))
|
|
182
232
|
|
|
183
233
|
const candidateOf = (row: PhaseDependency, byId: ReadonlyMap<string, PhaseDependency>): Candidate[] => {
|
|
184
234
|
const writes = writeSetOf(row, byId, [])
|
|
185
|
-
|
|
235
|
+
if (writes === null || writes.length === 0) return []
|
|
236
|
+
const releases = [...new Set([...row.releases, ...releasedByFiles(writes)])]
|
|
237
|
+
return [{ id: row.id, writes, serialMarkers: row.serialMarkers, releases }]
|
|
186
238
|
}
|
|
187
239
|
|
|
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[][] => {
|
|
240
|
+
const candidateGroups = (rows: readonly PhaseDependency[], doneIds: ReadonlySet<string>): Candidate[][] => {
|
|
199
241
|
const byId = new Map(rows.map((row) => [row.id, row]))
|
|
200
242
|
const ready = rows
|
|
201
243
|
.filter((row) => !doneIds.has(row.id) && row.external.length === 0 && row.after.every((id) => doneIds.has(id)))
|
|
@@ -206,22 +248,92 @@ export const parallelGroups = (rows: readonly PhaseDependency[], doneIds: Readon
|
|
|
206
248
|
if (group) group.push(candidate)
|
|
207
249
|
else groups.push([candidate])
|
|
208
250
|
}
|
|
209
|
-
return groups
|
|
251
|
+
return groups
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The phases that can run side by side, as groups in table order: the first group can start now, a later one holds
|
|
256
|
+
* the ready phases that collide with an earlier group's members. A phase is offered when it is not in `doneIds`,
|
|
257
|
+
* every phase in its `Depends on` cell is, and the cell names no external gate: nothing in the plan says such a gate
|
|
258
|
+
* holds, so the phase stays out and the caller asks. Two phases share a group only when no path of one lies at or
|
|
259
|
+
* below a path of the other (a root path lies below nothing and above everything) and they share no serial marker,
|
|
260
|
+
* compared without case, and release no package the other releases: a release marker naming it (`Release jackherd
|
|
261
|
+
* 1.8.0`) or a write of its release files (`CHANGELOG.md`, `package.json` in its directory), the package compared by
|
|
262
|
+
* its name without scope and the last segment of its directory. A release marker without a name keeps nobody apart.
|
|
263
|
+
* A `wie N` writes cell takes the set of phase N; a phase whose set is empty, whose `wie` target is unknown, or whose
|
|
264
|
+
* `wie` chain is a cycle, cannot be shown disjoint and stays out. Whether two phases collide in content is not decided here.
|
|
265
|
+
*/
|
|
266
|
+
export const parallelGroups = (rows: readonly PhaseDependency[], doneIds: ReadonlySet<string>): string[][] =>
|
|
267
|
+
candidateGroups(rows, doneIds).map((group) => group.map(({ id }) => id))
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The whole sequence of parallel groups, computed ahead: the first wave is the first group of `parallelGroups`, each
|
|
271
|
+
* later wave the first group once the waves before it are done. A phase `parallelGroups` never offers (an external
|
|
272
|
+
* gate, an empty or unresolvable writes set) is in no wave, and neither is a phase waiting for it.
|
|
273
|
+
*/
|
|
274
|
+
export const phaseWaves = (rows: readonly PhaseDependency[], doneIds: ReadonlySet<string>): string[][] => {
|
|
275
|
+
const waves: string[][] = []
|
|
276
|
+
const done = new Set(doneIds)
|
|
277
|
+
let wave = parallelGroups(rows, done)[0]
|
|
278
|
+
while (wave) {
|
|
279
|
+
waves.push(wave)
|
|
280
|
+
for (const id of wave) done.add(id)
|
|
281
|
+
wave = parallelGroups(rows, done)[0]
|
|
282
|
+
}
|
|
283
|
+
return waves
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The longest chain of open phases in `waves`, counted in phases: a phase follows another that it depends on, or one
|
|
288
|
+
* of an earlier wave it shares a serial marker or a package release with. A shared path alone does not chain, so a
|
|
289
|
+
* plan with more waves than its critical path is long loses the difference to overlapping writes. The first longest
|
|
290
|
+
* chain in wave order; empty without waves.
|
|
291
|
+
*/
|
|
292
|
+
export const criticalPath = (rows: readonly PhaseDependency[], waves: readonly string[][]): string[] => {
|
|
293
|
+
const byId = new Map(rows.map((row) => [row.id, row]))
|
|
294
|
+
const candidates = new Map(rows.flatMap((row) => candidateOf(row, byId)).map((each) => [each.id, each]))
|
|
295
|
+
const chains = new Map<string, string[]>()
|
|
296
|
+
const follows = (later: string, earlier: string): boolean => {
|
|
297
|
+
const [first, second] = [candidates.get(earlier), candidates.get(later)]
|
|
298
|
+
const chained = first !== undefined && second !== undefined && sharesMarker(first, second)
|
|
299
|
+
return chained || (byId.get(later)?.after.includes(earlier) ?? false)
|
|
300
|
+
}
|
|
301
|
+
waves.forEach((wave, index) => {
|
|
302
|
+
const earlier = waves.slice(0, index).flat()
|
|
303
|
+
for (const id of wave) {
|
|
304
|
+
const before = longestChain(earlier.filter((other) => follows(id, other)).map((other) => chains.get(other) ?? []))
|
|
305
|
+
chains.set(id, [...before, id])
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
return longestChain([...chains.values()])
|
|
210
309
|
}
|
|
211
310
|
|
|
212
311
|
/** 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(' ∥ ')
|
|
312
|
+
export const phasesText = (phases: readonly string[]): string => phaseLabels(phases, ' ∥ ')
|
|
215
313
|
|
|
216
314
|
const donePhaseIds = (markdown: string, language: PlanLanguage): Set<string> =>
|
|
217
315
|
new Set(
|
|
218
316
|
parsePlanPhases(markdown, language)
|
|
219
|
-
.filter(
|
|
317
|
+
.filter(isPhaseFinished)
|
|
220
318
|
.map(({ id }) => id),
|
|
221
319
|
)
|
|
222
320
|
|
|
223
|
-
/** `parallelGroups` of the plan's own dependency table and ticks (a phase is done
|
|
321
|
+
/** `parallelGroups` of the plan's own dependency table and ticks (a phase is done once `isPhaseFinished`); null for a plan without a table. */
|
|
224
322
|
export const parallelGroupsOfPlan = (markdown: string, language: PlanLanguage): string[][] | null => {
|
|
225
323
|
const table = parseDependencyTable(markdown, language)
|
|
226
324
|
return table === null ? null : parallelGroups(table, donePhaseIds(markdown, language))
|
|
227
325
|
}
|
|
326
|
+
|
|
327
|
+
/** The waves, the critical path and the open phases in no wave, of the plan's own table and ticks; null for a plan without a table. */
|
|
328
|
+
export const wavesOfPlan = (markdown: string, language: PlanLanguage): PhaseSchedule | null => {
|
|
329
|
+
const table = parseDependencyTable(markdown, language)
|
|
330
|
+
if (table === null) return null
|
|
331
|
+
const done = donePhaseIds(markdown, language)
|
|
332
|
+
const waves = phaseWaves(table, done)
|
|
333
|
+
const placed = new Set(waves.flat())
|
|
334
|
+
return {
|
|
335
|
+
waves,
|
|
336
|
+
criticalPath: criticalPath(table, waves),
|
|
337
|
+
unplaced: table.filter(({ id }) => !done.has(id) && !placed.has(id)).map(({ id }) => id),
|
|
338
|
+
}
|
|
339
|
+
}
|
package/src/plan-format/index.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
export * from '../vocabulary'
|
|
2
2
|
export * from './board-prose'
|
|
3
3
|
export {
|
|
4
|
+
criticalPath,
|
|
4
5
|
type PhaseDependency,
|
|
6
|
+
type PhaseSchedule,
|
|
5
7
|
type PhaseWithoutRow,
|
|
6
8
|
parallelGroups,
|
|
7
9
|
parallelGroupsOfPlan,
|
|
8
10
|
parseDependencyTable,
|
|
9
11
|
phasesText,
|
|
10
12
|
phasesWithoutDependencyRow,
|
|
13
|
+
phaseWaves,
|
|
14
|
+
wavesOfPlan,
|
|
11
15
|
} from './dependencies'
|
|
12
16
|
export * from './documentation-gate'
|
|
13
17
|
export * from './drift'
|
|
@@ -18,6 +22,8 @@ export {
|
|
|
18
22
|
type PlanStep,
|
|
19
23
|
parseSprintLanes,
|
|
20
24
|
phaseIdsOfCell,
|
|
25
|
+
phaseLabel,
|
|
26
|
+
phaseLabels,
|
|
21
27
|
resolvePlanPath,
|
|
22
28
|
type SprintLane,
|
|
23
29
|
sprintTitleOf,
|
package/src/plan-format/lanes.ts
CHANGED
|
@@ -29,6 +29,12 @@ const PLAN_LINK = /\[([^\]]+)\]\(([^)]+)\)/
|
|
|
29
29
|
/** Sprint tables name a phase `P4`; the sidebar title prints it the same way. */
|
|
30
30
|
export const PHASE_ID_PREFIX = 'P'
|
|
31
31
|
|
|
32
|
+
/** A phase as plans and reports name it: `P4`. */
|
|
33
|
+
export const phaseLabel = (id: string): string => `${PHASE_ID_PREFIX}${id}`
|
|
34
|
+
|
|
35
|
+
/** Phases as plans and reports name them, joined by `separator`: `P3 P4`, `P19 ∥ P20`. */
|
|
36
|
+
export const phaseLabels = (ids: readonly string[], separator: string): string => ids.map(phaseLabel).join(separator)
|
|
37
|
+
|
|
32
38
|
const PHASES_COLUMN = 3
|
|
33
39
|
const CLAUSE_SEPARATOR = /([;,]|\.(?=\s))\s*/
|
|
34
40
|
const ENTRY_END = new Set([';', '.'])
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type PlanStep, phaseLabel, type SprintLane } from './lanes'
|
|
2
2
|
import type { ChecklistItem, DeferredItem, PlanPhase } from './phases'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -37,7 +37,8 @@ export const sumPhases = (phases: readonly PhaseProgress[]): { done: number; tot
|
|
|
37
37
|
const phaseById = <T extends { id: string }>(phases: readonly T[], id: string): T | undefined =>
|
|
38
38
|
phases.find((phase) => phase.id === id)
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
/** The progress of the phase `id` of a plan; a phase the plan lacks has a null title and no checkboxes. */
|
|
41
|
+
export const phaseProgress = (phases: readonly PlanPhase[], id: string): PhaseProgress => {
|
|
41
42
|
const phase = phaseById(phases, id)
|
|
42
43
|
const items = phase?.items ?? []
|
|
43
44
|
return {
|
|
@@ -51,7 +52,17 @@ const phaseProgress = (phases: PlanPhase[], id: string): PhaseProgress => {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
/**
|
|
56
|
+
* A phase is finished once every checkbox it has is ticked, its tasks and its review gate alike: `/implement` and
|
|
57
|
+
* `jh check --parallel` read it so, and an open documentation box keeps the phase open. A phase without a checkbox is
|
|
58
|
+
* never finished.
|
|
59
|
+
*/
|
|
60
|
+
export const isPhaseFinished = ({ items }: Pick<PlanPhase, 'items'>): boolean =>
|
|
61
|
+
items.length > 0 && items.every(({ done }) => done)
|
|
62
|
+
|
|
63
|
+
/** A phase with a checkbox that is not finished; a phase without a checkbox is neither. */
|
|
64
|
+
export const isPhaseOpen = (phase: Pick<PlanPhase, 'items'>): boolean =>
|
|
65
|
+
phase.items.length > 0 && !isPhaseFinished(phase)
|
|
55
66
|
|
|
56
67
|
/** A phase the plan has written: one with at least one checkbox, not one missing from the plan or without any. */
|
|
57
68
|
const isWritten = ({ total }: PhaseProgress): boolean => total > 0
|
|
@@ -68,7 +79,7 @@ const isTouched = (phase: PhaseProgress): boolean => phase.touched === true && i
|
|
|
68
79
|
* A phase the lane ticked to the end in its worktree while the base branch does not carry those ticks yet: still
|
|
69
80
|
* the lane's work until it is merged, not a finished phase.
|
|
70
81
|
*/
|
|
71
|
-
export const isTickedUnmerged = (phase: PhaseProgress): boolean => isTouched(phase) &&
|
|
82
|
+
export const isTickedUnmerged = (phase: PhaseProgress): boolean => isTouched(phase) && isPhaseFinished(phase)
|
|
72
83
|
|
|
73
84
|
/** The first phase `pick` finds in a step, from the newest step back. */
|
|
74
85
|
const newestStepWith = (
|
|
@@ -112,7 +123,7 @@ const rowPhases = (steps: readonly StepProgress[], stepIndex: number): LocatedPh
|
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
const touchedOpenPhaseOf = (phases: readonly PhaseProgress[]): PhaseProgress | undefined =>
|
|
115
|
-
phases.find((phase) => isTouched(phase) &&
|
|
126
|
+
phases.find((phase) => isTouched(phase) && isPhaseOpen(phase))
|
|
116
127
|
|
|
117
128
|
const lastTickedUnmergedOf = (phases: readonly PhaseProgress[]): PhaseProgress | undefined =>
|
|
118
129
|
phases.filter(isTickedUnmerged).at(-1)
|
|
@@ -126,7 +137,7 @@ const lastTickedUnmergedOf = (phases: readonly PhaseProgress[]): PhaseProgress |
|
|
|
126
137
|
const newestRowPhase = (steps: readonly StepProgress[]): LaneProgress['current'] => {
|
|
127
138
|
const located = rowPhases(steps, newestRowIndex(steps)).filter(({ phase }) => isWritten(phase))
|
|
128
139
|
const phases = located.map(({ phase }) => phase)
|
|
129
|
-
const phase = touchedOpenPhaseOf(phases) ?? lastTickedUnmergedOf(phases) ?? phases.find(
|
|
140
|
+
const phase = touchedOpenPhaseOf(phases) ?? lastTickedUnmergedOf(phases) ?? phases.find(isPhaseOpen)
|
|
130
141
|
return located.find((entry) => entry.phase === phase)?.current ?? null
|
|
131
142
|
}
|
|
132
143
|
|
|
@@ -141,7 +152,7 @@ const phaseFromTicks = (steps: readonly StepProgress[]): LaneProgress['current']
|
|
|
141
152
|
const touched = newestStepWith(steps, touchedOpenPhaseOf) ?? newestStepWith(steps, lastTickedUnmergedOf)
|
|
142
153
|
if (touched) return touched
|
|
143
154
|
const lastIndex = newestWrittenStepIndex(steps)
|
|
144
|
-
const phase = steps[lastIndex]?.phases.find(
|
|
155
|
+
const phase = steps[lastIndex]?.phases.find(isPhaseOpen)
|
|
145
156
|
return phase ? { stepIndex: lastIndex, phaseId: phase.id } : null
|
|
146
157
|
}
|
|
147
158
|
|
|
@@ -175,6 +186,6 @@ export const activeItem = (phase: Pick<PhaseProgress, 'items'>): ChecklistItem |
|
|
|
175
186
|
/** The sidebar title of a lane, e.g. `P4 · 7/12` or `P4 · 12/12` while ticked and not merged; null when nothing of the lane could be counted. */
|
|
176
187
|
export const shortTitle = (progress: LaneProgress): string | null => {
|
|
177
188
|
const phase = currentPhase(progress)
|
|
178
|
-
if (phase) return `${
|
|
189
|
+
if (phase) return `${phaseLabel(phase.id)} · ${phase.done}/${phase.total}`
|
|
179
190
|
return progress.total > 0 ? `✓ ${progress.done}/${progress.total}` : null
|
|
180
191
|
}
|
package/src/plans/command.ts
CHANGED
|
@@ -6,26 +6,35 @@ import { boardFor, foundLanesOf } from './board-pick'
|
|
|
6
6
|
import { ensurePlanPanes } from './ensure-panes'
|
|
7
7
|
import { mainCheckout, worktreeRoot } from './git'
|
|
8
8
|
import { fetchPullRequests } from './github'
|
|
9
|
+
import { recallLanes, rememberLanes } from './herd-memory'
|
|
9
10
|
import { runLive } from './live'
|
|
10
11
|
import { parseOptions } from './options'
|
|
11
12
|
import { isSupervisedChild } from './restart-decision'
|
|
12
|
-
import {
|
|
13
|
+
import { CLOCK_LOCALE, readSettings } from './settings'
|
|
13
14
|
import { loadSnapshot, type Source } from './snapshot'
|
|
14
15
|
import { superviseView } from './supervisor'
|
|
15
16
|
import { frameText, paneWidth, renderSnapshot, startSelection } from './view'
|
|
16
17
|
|
|
17
18
|
const printOnce = async (source: Source): Promise<void> => {
|
|
18
|
-
const
|
|
19
|
+
const memories = recallLanes(source.root)
|
|
20
|
+
const snapshot = await loadSnapshot(source, await fetchPullRequests(source.root), memories)
|
|
21
|
+
rememberLanes(source.root, memories, snapshot.documents.laneOrder)
|
|
19
22
|
const screen = renderSnapshot(
|
|
20
23
|
snapshot,
|
|
21
24
|
startSelection(snapshot, source.options),
|
|
22
25
|
snapshot.errors,
|
|
23
26
|
paneWidth(),
|
|
24
|
-
|
|
27
|
+
CLOCK_LOCALE,
|
|
25
28
|
)
|
|
26
29
|
console.log(frameText(screen, Boolean(process.stdout.isTTY)))
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/** One print, with why this board was chosen once on stderr: never in the frame (the watcher's `armed:` keeps it). */
|
|
33
|
+
const printOnceWithNotice = async (source: Source, notice: string | null): Promise<void> => {
|
|
34
|
+
if (notice) console.error(notice)
|
|
35
|
+
await printOnce(source)
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
/**
|
|
30
39
|
* `jh herd [<board.md>] [--lane <name>] [--once | --ensure]`: a live view of a board's lanes and their plan
|
|
31
40
|
* progress; `--ensure` instead brings back the missing herd panes beside the coordinator and each lane.
|
|
@@ -48,8 +57,8 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
48
57
|
for (const line of lines.length > 0 ? lines : ['every herd pane is running']) console.log(line)
|
|
49
58
|
return 0
|
|
50
59
|
}
|
|
51
|
-
const source = { root, sprintFile: picked.file, options, settings, terminal
|
|
52
|
-
if (options.once || !process.stdout.isTTY) await
|
|
60
|
+
const source = { root, sprintFile: picked.file, options, settings, terminal }
|
|
61
|
+
if (options.once || !process.stdout.isTTY) await printOnceWithNotice(source, picked.notice)
|
|
53
62
|
else if (isSupervisedChild(process.env)) runLive(source)
|
|
54
63
|
else return await superviseView(args, await startedKit())
|
|
55
64
|
return 0
|
|
@@ -6,34 +6,90 @@ export type PhaseRef = { planHref: string; phaseId: string }
|
|
|
6
6
|
/** Per plan link and phase id, the state of its checkboxes as `1` (ticked) and `0` (open) in plan order. */
|
|
7
7
|
export type TickSnapshot = Record<string, Record<string, string>>
|
|
8
8
|
|
|
9
|
+
/** When a phase's ticks or task lines last moved, in milliseconds, by `activityKey`. */
|
|
10
|
+
export type ActivityTimes = Readonly<Record<string, number>>
|
|
11
|
+
|
|
9
12
|
/**
|
|
10
|
-
* What the herd remembers of a lane between two reads: the ticks it saw, the phase whose ticks moved last
|
|
11
|
-
* identity of the newest board row (`newestRowIdentity`), which a new row changes.
|
|
13
|
+
* What the herd remembers of a lane between two reads: the ticks it saw, the phase whose ticks moved last, the
|
|
14
|
+
* identity of the newest board row (`newestRowIdentity`), which a new row changes, and when each phase last moved.
|
|
12
15
|
*/
|
|
13
|
-
export type LaneMemory = { ticks: TickSnapshot; moved: PhaseRef | null; row: string }
|
|
16
|
+
export type LaneMemory = { ticks: TickSnapshot; moved: PhaseRef | null; row: string; activity?: ActivityTimes }
|
|
17
|
+
|
|
18
|
+
/** The key of a phase in `ActivityTimes`. */
|
|
19
|
+
export const activityKey = (planHref: string, phaseId: string): string => `${planHref}#${phaseId}`
|
|
20
|
+
|
|
21
|
+
/** Marks a tick string of the tasks alone; one remembered before, with the review gate in it, has no mark. */
|
|
22
|
+
const TASK_TICKS = 't:'
|
|
14
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The ticks of each phase's tasks outside the review gate (D67): ticking the gate is bookkeeping, not work on the
|
|
26
|
+
* phase, so it moves nothing in the herd.
|
|
27
|
+
*/
|
|
15
28
|
export const tickSnapshotOf = (plans: PlansByHref): TickSnapshot =>
|
|
16
29
|
Object.fromEntries(
|
|
17
30
|
Object.entries(plans).map(([href, phases]) => [
|
|
18
31
|
href,
|
|
19
32
|
Object.fromEntries(
|
|
20
|
-
phases.map((phase) => [
|
|
33
|
+
phases.map((phase) => [
|
|
34
|
+
phase.id,
|
|
35
|
+
`${TASK_TICKS}${phase.items
|
|
36
|
+
.filter((item) => !item.gate)
|
|
37
|
+
.map((item) => (item.done ? '1' : '0'))
|
|
38
|
+
.join('')}`,
|
|
39
|
+
]),
|
|
21
40
|
),
|
|
22
41
|
]),
|
|
23
42
|
)
|
|
24
43
|
|
|
25
44
|
/**
|
|
26
|
-
* The phases both reads have whose ticks differ, in plan order; a plan or phase new in `next
|
|
27
|
-
* from. A task added to or removed from a phase changes its tick string
|
|
45
|
+
* The phases both reads have whose ticks differ, in plan order; a plan or phase new in `next`, or one remembered in
|
|
46
|
+
* the format before D67, has no state to move from. A task added to or removed from a phase changes its tick string
|
|
47
|
+
* and counts as movement.
|
|
28
48
|
*/
|
|
29
49
|
export const movedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
|
|
30
50
|
Object.entries(next).flatMap(([planHref, phases]) =>
|
|
31
51
|
Object.entries(phases).flatMap(([phaseId, ticks]) => {
|
|
32
52
|
const before = previous[planHref]?.[phaseId]
|
|
33
|
-
return before
|
|
53
|
+
return before?.startsWith(TASK_TICKS) && before !== ticks ? [{ planHref, phaseId }] : []
|
|
34
54
|
}),
|
|
35
55
|
)
|
|
36
56
|
|
|
57
|
+
/** The phases `next` has in a plan `previous` read already but without them: task lines a lane wrote into a new phase. */
|
|
58
|
+
const addedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
|
|
59
|
+
Object.entries(next).flatMap(([planHref, phases]) => {
|
|
60
|
+
const before = previous[planHref]
|
|
61
|
+
if (before === undefined) return []
|
|
62
|
+
return Object.keys(phases).flatMap((phaseId) => (phaseId in before ? [] : [{ planHref, phaseId }]))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** When each plan file was last written, in milliseconds, by plan link; a plan without an entry is bounded by the clock. */
|
|
66
|
+
export type PlanModified = Readonly<Record<string, number>>
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* When each phase last moved, after a read at `now` whose ticks are `ticks`: a phase whose ticks or task lines changed
|
|
70
|
+
* since `previous`, or that is new in a plan `previous` read already, takes the time its plan file was last written
|
|
71
|
+
* (`modified`), bounded by `now`, so a change first seen long after it was made is not taken for a fresh one; the
|
|
72
|
+
* others keep their time. The first read knows no movement, so without a remembered read no phase has a time.
|
|
73
|
+
*/
|
|
74
|
+
export const nextActivity = (
|
|
75
|
+
previous: LaneMemory | undefined,
|
|
76
|
+
ticks: TickSnapshot,
|
|
77
|
+
now: number,
|
|
78
|
+
modified: PlanModified = {},
|
|
79
|
+
): ActivityTimes => {
|
|
80
|
+
if (!previous) return {}
|
|
81
|
+
const changed = [...movedPhases(previous.ticks, ticks), ...addedPhases(previous.ticks, ticks)]
|
|
82
|
+
return {
|
|
83
|
+
...previous.activity,
|
|
84
|
+
...Object.fromEntries(
|
|
85
|
+
changed.map(({ planHref, phaseId }) => [
|
|
86
|
+
activityKey(planHref, phaseId),
|
|
87
|
+
Math.min(now, modified[planHref] ?? now),
|
|
88
|
+
]),
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
37
93
|
/** The newest board row of a lane as its step index and the phase ids it shows, a step `withWorkedPhases` added not counted. */
|
|
38
94
|
export const newestRowIdentity = ({ steps }: LaneProgress): string => {
|
|
39
95
|
const index = steps.reduce((last, { step }, stepIndex) => (step.withoutRow ? last : stepIndex), -1)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { PlanPhase } from '../plan-format'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A hint that a phase's task is being worked on: `taskIndex` is the task's index in the phase's checkboxes, `at` when
|
|
5
|
+
* it was seen, in milliseconds. No source writes one yet, so the heuristic of `currentTasks` decides alone.
|
|
6
|
+
*/
|
|
7
|
+
export type TaskSignal = { phaseId: string; taskIndex: number; at: number }
|
|
8
|
+
|
|
9
|
+
/** A phase as `currentTasks` reads it: its id and checkboxes. */
|
|
10
|
+
export type TaskPhase = Pick<PlanPhase, 'id' | 'items'>
|
|
11
|
+
|
|
12
|
+
const isOpenTask = (phase: TaskPhase, index: number): boolean => phase.items[index]?.done === false
|
|
13
|
+
|
|
14
|
+
/** The task the newest signal younger than `staleAfterMs` points at, when it is still open; null otherwise. */
|
|
15
|
+
const signalledTask = (
|
|
16
|
+
phase: TaskPhase,
|
|
17
|
+
signals: readonly TaskSignal[],
|
|
18
|
+
now: number,
|
|
19
|
+
staleAfterMs: number,
|
|
20
|
+
): number | null => {
|
|
21
|
+
const fresh = signals
|
|
22
|
+
.filter((signal) => signal.phaseId === phase.id && now - signal.at < staleAfterMs)
|
|
23
|
+
.filter((signal) => isOpenTask(phase, signal.taskIndex))
|
|
24
|
+
const newest = fresh.reduce<TaskSignal | null>((last, signal) => (last && last.at >= signal.at ? last : signal), null)
|
|
25
|
+
return newest?.taskIndex ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The first open task outside the review gate; with only gate boxes open, the first of them; -1 when none is open. */
|
|
29
|
+
const firstOpenTask = ({ items }: TaskPhase): number => {
|
|
30
|
+
const outsideGate = items.findIndex((item) => !item.done && !item.gate)
|
|
31
|
+
return outsideGate === -1 ? items.findIndex((item) => !item.done) : outsideGate
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The task each phase is on, by phase id, as its index in the phase's checkboxes: the open task a fresh signal names
|
|
36
|
+
* (the newest), else the first open task outside the review gate, else the first open gate box. A phase without an
|
|
37
|
+
* open task is absent. Derived only, never written into the plan.
|
|
38
|
+
*/
|
|
39
|
+
export const currentTasks = (
|
|
40
|
+
plan: readonly TaskPhase[],
|
|
41
|
+
signals: readonly TaskSignal[],
|
|
42
|
+
now: number,
|
|
43
|
+
staleAfterMs: number,
|
|
44
|
+
): ReadonlyMap<string, number> =>
|
|
45
|
+
new Map(
|
|
46
|
+
plan.flatMap((phase) => {
|
|
47
|
+
const first = firstOpenTask(phase)
|
|
48
|
+
if (first === -1) return []
|
|
49
|
+
return [[phase.id, signalledTask(phase, signals, now, staleAfterMs) ?? first] as const]
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { resolveCoordinatorName } from '../context/coordinator-name'
|
|
4
4
|
import { parseSprintLanes } from '../plan-format'
|
|
5
|
-
import { parseAgentList, runHerdr } from '../terminal/herdr'
|
|
5
|
+
import { parseAgentList, renameArguments, runHerdr } from '../terminal/herdr'
|
|
6
6
|
import { errorText } from './error-text'
|
|
7
7
|
import {
|
|
8
8
|
agentPanesOf,
|
|
9
|
+
coordinatorLabelOf,
|
|
9
10
|
coordinatorPaneOf,
|
|
10
11
|
foregroundOf,
|
|
11
12
|
missingCoordinatorNote,
|
|
@@ -46,21 +47,37 @@ const apply = (action: PaneAction): string => {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/** Labels the coordinator's pane; the label is decoration, so a failure is dropped. */
|
|
51
|
+
const labelCoordinator = (label: { pane: string; label: string } | null) => {
|
|
52
|
+
if (!label) return
|
|
53
|
+
try {
|
|
54
|
+
runHerdr(renameArguments(label.pane, label.label))
|
|
55
|
+
} catch {
|
|
56
|
+
// A pane herdr cannot rename keeps its old label; the views are what counts here.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
/**
|
|
50
61
|
* `herd --ensure`: brings back every missing herd pane beside the coordinator and each lane of the board, and
|
|
51
62
|
* leaves running ones alone, so it can run after every start. A lane gets its view once its agent runs. One line per
|
|
52
|
-
* action or skipped target, none when every view runs.
|
|
63
|
+
* action or skipped target, none when every view runs. The coordinator's pane is labelled `🐾 Jack` on the way.
|
|
53
64
|
*/
|
|
54
65
|
export const ensurePlanPanes = (root: string, settings: PlansSettings, boardFile: string): string[] => {
|
|
55
66
|
const agents = runHerdr(['agent', 'list'])
|
|
67
|
+
const listed = parseAgentList(agents)
|
|
56
68
|
const panes = panesWithForeground(agentPanesOf(agents))
|
|
57
|
-
const coordinatorName =
|
|
69
|
+
const coordinatorName = resolveCoordinatorName(
|
|
70
|
+
basename(root),
|
|
71
|
+
listed.map((agent) => agent.name),
|
|
72
|
+
)
|
|
73
|
+
const coordinator = coordinatorPaneOf(panes, root, {
|
|
74
|
+
named: listed.find((agent) => agent.name === coordinatorName)?.pane ?? null,
|
|
75
|
+
caller: process.env.HERDR_PANE_ID ?? null,
|
|
76
|
+
})
|
|
77
|
+
labelCoordinator(coordinatorLabelOf(panes, coordinator))
|
|
58
78
|
const { actions, notes } = planPaneActions({
|
|
59
79
|
panes,
|
|
60
|
-
coordinator
|
|
61
|
-
named: parseAgentList(agents).find((agent) => agent.name === coordinatorName)?.pane ?? null,
|
|
62
|
-
caller: process.env.HERDR_PANE_ID ?? null,
|
|
63
|
-
}),
|
|
80
|
+
coordinator,
|
|
64
81
|
lanes: lanesOf(root, settings, boardFile),
|
|
65
82
|
root,
|
|
66
83
|
worktrees: join(root, settings.worktreeDirectory),
|