jackherd 1.6.1 → 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 +26 -0
- package/INSTALL.md +176 -0
- package/README.md +226 -310
- 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 +109 -27
- 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
package/src/config/schema.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
129
|
-
|
|
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
|
|
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
|
|
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]).
|
|
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
|
+
}
|
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 `####`
|