jackherd 1.6.1 → 1.7.1

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.
Files changed (156) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +228 -312
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +109 -27
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/legacy-names.ts +8 -0
  81. package/src/config/nested-quantifier.ts +73 -0
  82. package/src/config/repository-directory.ts +11 -0
  83. package/src/config/schema.ts +63 -8
  84. package/src/plan-format/board-prose.ts +36 -0
  85. package/src/plan-format/dependencies.ts +227 -0
  86. package/src/plan-format/documentation-gate.ts +16 -0
  87. package/src/plan-format/index.ts +23 -1
  88. package/src/plan-format/lanes.ts +35 -1
  89. package/src/plan-format/model-column.ts +52 -0
  90. package/src/plan-format/phases.ts +16 -1
  91. package/src/plan-format/plan-structure.ts +10 -28
  92. package/src/plan-format/progress.ts +7 -29
  93. package/src/plans/command.ts +3 -3
  94. package/src/plans/current-phase.ts +71 -0
  95. package/src/plans/ensure-panes.ts +2 -2
  96. package/src/plans/herd-command.ts +4 -0
  97. package/src/plans/lane-discovery.ts +14 -2
  98. package/src/plans/lane-tickets.ts +69 -0
  99. package/src/plans/layout.ts +182 -88
  100. package/src/plans/live.ts +5 -1
  101. package/src/plans/phase-models-read.ts +37 -0
  102. package/src/plans/phase-models.ts +70 -0
  103. package/src/plans/plan-panes.ts +8 -7
  104. package/src/plans/pull-requests.ts +78 -4
  105. package/src/plans/selection.ts +1 -40
  106. package/src/plans/settings.ts +9 -3
  107. package/src/plans/shown-with-more.ts +5 -0
  108. package/src/plans/snapshot.ts +82 -8
  109. package/src/plans/sprint-html.ts +2 -2
  110. package/src/plans/supervisor.ts +5 -4
  111. package/src/plans/symbols.ts +9 -6
  112. package/src/plans/texts.ts +30 -13
  113. package/src/plans/view.ts +1 -0
  114. package/src/sync/command.ts +16 -1
  115. package/src/sync/herdr-plugin.ts +1 -1
  116. package/src/sync/next-steps.ts +1 -1
  117. package/src/sync/render.ts +2 -1
  118. package/src/sync/setup-questions.ts +2 -2
  119. package/src/terminal/text.ts +4 -0
  120. package/src/terminal/types.ts +1 -1
  121. package/src/update/command.ts +4 -2
  122. package/src/update/update.ts +33 -9
  123. package/src/vocabulary/index.ts +39 -0
  124. package/src/watch/approvals.ts +96 -0
  125. package/src/watch/command.ts +154 -50
  126. package/src/watch/failed-tests.ts +52 -0
  127. package/src/watch/failure-read.ts +56 -0
  128. package/src/watch/failures.ts +107 -0
  129. package/src/watch/feedback-item.ts +21 -0
  130. package/src/watch/feedback-read.ts +2 -1
  131. package/src/watch/feedback.ts +37 -28
  132. package/src/watch/gh-process.ts +53 -0
  133. package/src/watch/gh.ts +10 -20
  134. package/src/watch/heartbeat-progress.ts +1 -1
  135. package/src/watch/lane-model.ts +54 -0
  136. package/src/watch/lane-parallel.ts +46 -0
  137. package/src/watch/lane-progress.ts +28 -4
  138. package/src/watch/lane-report.ts +62 -9
  139. package/src/watch/lane-sources.ts +1 -1
  140. package/src/watch/no-finding.ts +47 -0
  141. package/src/watch/plan-badges-read.ts +19 -7
  142. package/src/watch/plan-badges.ts +59 -7
  143. package/src/watch/plan-views.ts +5 -5
  144. package/src/watch/previews.ts +3 -9
  145. package/src/watch/pull-requests.ts +12 -2
  146. package/src/watch/recorded-reads.ts +25 -0
  147. package/src/watch/source-fields.ts +4 -0
  148. package/src/watch/source-tickets.ts +99 -0
  149. package/src/watch/sources.ts +62 -15
  150. package/src/watch/texts.ts +21 -1
  151. package/src/watch/watch-defaults.ts +8 -0
  152. package/src/watch/watch-events.ts +64 -4
  153. package/src/watch/watcher-lock.ts +1 -1
  154. package/src/watch/workflow-runs.ts +5 -7
  155. package/docs/lanes.mmd +0 -9
  156. package/docs/skills.mmd +0 -10
@@ -2,10 +2,13 @@ import { z } from 'zod'
2
2
  import { checkConfigSchema } from '../check/schema'
3
3
  import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
4
4
  import { PLAN_LANGUAGE, RESPONSE_ADDRESS } from '../vocabulary'
5
- import { SOURCE_FIELD } from '../watch/source-fields'
5
+ import { CONTENT_FIELDS, SOURCE_FIELD } from '../watch/source-fields'
6
6
  import { isSourcePath } from '../watch/source-path'
7
+ import { BUN_FAILED_TEST_PATTERN, CLEAN_REVIEW_TEXT } from '../watch/watch-defaults'
7
8
  import { BUN_COMMANDS, DEFAULT_BASE_BRANCH } from './detect'
8
9
  import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
10
+ import { hasNestedQuantifier } from './nested-quantifier'
11
+ import { isRepositoryDirectory } from './repository-directory'
9
12
 
10
13
  export const TERMINAL_ADAPTER = { none: 'none', herdr: 'herdr' } as const
11
14
 
@@ -89,7 +92,7 @@ const boardSchema = z.strictObject({
89
92
  pattern: boardPattern.default('board-*'),
90
93
  /** The board document inside each of those directories. */
91
94
  file: boardFile.default('board.md'),
92
- /** What the project calls a board: the plans view, its page and the watcher print it as written. */
95
+ /** What the project calls a board: the herd, its page and the watcher print it as written. */
93
96
  term: z.string().min(1).default('board'),
94
97
  })
95
98
 
@@ -103,6 +106,28 @@ const deploySchema = z.strictObject({
103
106
  const feedbackSchema = z.strictObject({
104
107
  /** GitHub logins whose feedback does not wake the coordinator, such as a status bot; case is ignored. */
105
108
  ignore: z.array(z.string().min(1)).default([]),
109
+ /**
110
+ * Texts of a bot's pull request comment that report no finding: such a comment (login ending `[bot]`, no inline
111
+ * comment by the same author within five minutes of it) is no feedback. Compared with whitespace collapsed; the
112
+ * default is the CI code review's comment for a clean run. An empty list counts every bot comment.
113
+ */
114
+ noFindingTemplates: z.array(z.string().min(1)).default([CLEAN_REVIEW_TEXT]),
115
+ })
116
+
117
+ const checksSchema = z.strictObject({
118
+ /**
119
+ * Finds a failed test in a line of a red run's log (`gh run view --log-failed`) after its job, step and timestamp
120
+ * prefix: the first group is the name, else the match. The default is Bun's `(fail) <name> [<duration>]`. The
121
+ * watcher runs it on every line of a log it does not control, so a pattern that repeats a group which repeats inside
122
+ * itself (`(a+)+`, `(\w*)*`, and with alternation `(a|aa)+`) is refused: a best-effort scan of the source against
123
+ * catastrophic backtracking, not a proof.
124
+ */
125
+ failedTestPattern: z
126
+ .string()
127
+ .min(1)
128
+ .refine(isRegularExpression, 'not a valid regular expression')
129
+ .refine((pattern) => !hasNestedQuantifier(pattern), 'a quantified group that holds a quantifier backtracks badly')
130
+ .default(BUN_FAILED_TEST_PATTERN),
106
131
  })
107
132
 
108
133
  const previewsSchema = z.strictObject({
@@ -121,24 +146,33 @@ const sourcePath = z.string().refine(isSourcePath, 'not a source path: `.`, or `
121
146
 
122
147
  const SOURCE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
123
148
 
124
- /** The six known fields, and any further field (`status`, `priority`, …) whose name is a plain name, for `reportOn`. */
149
+ /** The seven known fields, and any further field (`priority`, `assignee`, …) whose name is a plain name, for `reportOn`. */
125
150
  const sourceMapSchema = z
126
151
  .object({
127
152
  [SOURCE_FIELD.id]: sourcePath,
128
- /** Changes whenever the entry changes; compared as text, so any stable form of a time or version works. */
129
- [SOURCE_FIELD.updatedAt]: sourcePath,
153
+ /**
154
+ * Changes whenever the entry changes; compared as text, so any stable form of a time or version works. Needed
155
+ * only where no content field and no `reportOn` can tell a change.
156
+ */
157
+ [SOURCE_FIELD.updatedAt]: sourcePath.optional(),
130
158
  [SOURCE_FIELD.title]: sourcePath.optional(),
131
159
  /** Shown only as an `https:` URL without user name or password. */
132
160
  [SOURCE_FIELD.url]: sourcePath.optional(),
133
161
  /** The first line with text is shown. */
134
162
  [SOURCE_FIELD.body]: sourcePath.optional(),
135
163
  [SOURCE_FIELD.author]: sourcePath.optional(),
164
+ /** A ticket source's status, shown on its badge in the herd; also a field `reportOn` can name. */
165
+ [SOURCE_FIELD.status]: sourcePath.optional(),
136
166
  })
137
167
  .catchall(sourcePath)
138
168
  .refine((map) => Object.keys(map).every((field) => SOURCE_FIELD_NAME.test(field)), {
139
169
  message: 'a map field is a letter, then letters, digits and `_`',
140
170
  })
141
171
 
172
+ /** Whether an entry of the source can change at all: by `updatedAt`, a content field or a `reportOn` field. */
173
+ const canChange = ({ map, reportOn }: { map: Record<string, string | undefined>; reportOn?: string[] }): boolean =>
174
+ map.updatedAt !== undefined || CONTENT_FIELDS.some((field) => map[field] !== undefined) || reportOn !== undefined
175
+
142
176
  const sourceSchema = z
143
177
  .strictObject({
144
178
  /** Printed in every report of the source and the key of its saved state. */
@@ -156,12 +190,21 @@ const sourceSchema = z
156
190
  reportLimit: z.number().int().min(1).default(5),
157
191
  /** The lane the source's reports name. */
158
192
  lane: z.string().min(1).optional(),
193
+ /**
194
+ * Marks the entries as tickets: the herd shows each as a badge beside the plan of the lane it belongs to,
195
+ * the lane above or the lane whose board row or plan names its id.
196
+ */
197
+ ticket: z.boolean().optional(),
159
198
  /**
160
199
  * The mapped fields whose change reports, with the old and the new value, in place of the content of `title`,
161
200
  * `author`, `body` and `url`; without it a change of that content reports.
162
201
  */
163
202
  reportOn: z.array(z.string().min(1)).min(1).max(10).optional(),
164
203
  })
204
+ .refine(canChange, {
205
+ message: 'map needs updatedAt, a content field (title, author, body, url) or reportOn',
206
+ path: ['map'],
207
+ })
165
208
  .refine((source) => source.ignoreAuthors.length === 0 || source.map.author !== undefined, {
166
209
  message: 'ignoreAuthors needs map.author',
167
210
  path: ['ignoreAuthors'],
@@ -232,6 +275,12 @@ const guardSchema = z.strictObject({
232
275
  approvalMinutes: z.number().int().min(1).max(1440).default(180),
233
276
  })
234
277
  .prefault({}),
278
+ approvals: z
279
+ .strictObject({
280
+ /** How long the approval gate hook holds a lane's call for the coordinator's dialog before it denies it; below the hook's 10 minutes. */
281
+ waitMinutes: z.number().int().min(1).max(9).default(8),
282
+ })
283
+ .prefault({}),
235
284
  })
236
285
 
237
286
  const hintsSchema = z.strictObject({
@@ -267,7 +316,11 @@ export const jackherdConfigSchema = z.strictObject({
267
316
  sprintPattern: z
268
317
  .never({ error: 'sprintPattern was replaced by board.pattern (with board.file and board.term)' })
269
318
  .optional(),
270
- worktreeDirectory: z.string().min(1).default(claudePath(WORKTREES_DIRECTORY)),
319
+ worktreeDirectory: z
320
+ .string()
321
+ .min(1)
322
+ .refine(isRepositoryDirectory, 'must lie below the repository root: relative, not `.`, without `..`')
323
+ .default(claudePath(WORKTREES_DIRECTORY)),
271
324
  baseBranch: branchName.default(DEFAULT_BASE_BRANCH),
272
325
  /**
273
326
  * Put before every skill and agent name. `jh sync --global` refuses to run without one: a personal skill
@@ -304,7 +357,7 @@ export const jackherdConfigSchema = z.strictObject({
304
357
  .prefault({}),
305
358
  plans: z
306
359
  .strictObject({
307
- /** Minutes between background fetches of the base branch while `jh plans` runs; 0 turns them off. */
360
+ /** Minutes between background fetches of the base branch while `jh herd` runs; 0 turns them off. */
308
361
  fetchIntervalMinutes: z.number().min(0).default(5),
309
362
  /** Links in a plan that the sprint page shows as chips; the list replaces the built-in kinds. */
310
363
  linkKinds: z
@@ -322,13 +375,15 @@ export const jackherdConfigSchema = z.strictObject({
322
375
  /**
323
376
  * Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
324
377
  * it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
378
+ * `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
325
379
  * Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
326
380
  * command whose JSON output reports `source` for a new or changed entry.
327
381
  */
328
382
  watch: z
329
383
  .strictObject({
330
384
  deploy: deploySchema.optional(),
331
- feedback: z.union([z.literal(false), feedbackSchema]).default({ ignore: [] }),
385
+ feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
386
+ checks: checksSchema.prefault({}),
332
387
  previews: previewsSchema.optional(),
333
388
  sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
334
389
  })
@@ -0,0 +1,36 @@
1
+ import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
+ import { changedOpenLaneRows } from './lanes'
3
+
4
+ /**
5
+ * The longest phases cell, in characters, that still reads as a phase and its state. Longer cells in the open
6
+ * lanes of the real boards carry prose, which belongs in the plan.
7
+ */
8
+ export const PHASE_CELL_LIMIT = 250
9
+
10
+ export const PROSE_REASON = { long: 'long', sideOrder: 'side-order' } as const
11
+ export type ProseReason = (typeof PROSE_REASON)[keyof typeof PROSE_REASON]
12
+
13
+ /** A changed row of an open lane (1-based line) whose phases cell is `length` characters and carries prose. */
14
+ export type RowWithProse = { line: number; lane: string; reason: ProseReason; length: number }
15
+
16
+ const reasonOf = (phases: string, marker: string): ProseReason | null => {
17
+ if (phases.includes(marker)) return PROSE_REASON.sideOrder
18
+ return phases.length > PHASE_CELL_LIMIT ? PROSE_REASON.long : null
19
+ }
20
+
21
+ /**
22
+ * The changed rows of open lanes whose phases cell is longer than `PHASE_CELL_LIMIT` or records a side order of the
23
+ * user in prose (`dazu (User …`): the board row names the phase, the side order is a task in the plan's phase.
24
+ * A cell that is both counts once, as a side order.
25
+ */
26
+ export const boardRowsWithProse = (
27
+ markdown: string,
28
+ language: PlanLanguage,
29
+ previousLines: ReadonlySet<string>,
30
+ ): RowWithProse[] => {
31
+ const { sideOrderMarker } = PLAN_VOCABULARY[language]
32
+ return changedOpenLaneRows(markdown, language, previousLines).flatMap(({ line, lane, phases }) => {
33
+ const reason = reasonOf(phases, sideOrderMarker)
34
+ return reason === null ? [] : [{ line, lane, reason, length: phases.length }]
35
+ })
36
+ }
@@ -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
+ }
@@ -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 * from './lanes'
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'
@@ -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
- const NUMBERED_SECTION = /^##\s+(\d+[a-z]?)\s*[—–:-]\s*(.*)$/
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 `####`