jackherd 1.14.0 → 1.15.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.
- package/CHANGELOG.md +20 -0
- package/README.md +27 -19
- package/assets/icons/skills.svg +1 -0
- package/dist/config.d.ts +9 -1
- package/dist/config.js +24 -1
- package/dist/plan-format.d.ts +24 -1
- package/dist/plan-format.js +25 -5
- package/docs/plan-contract.md +84 -0
- package/docs/skills-dark.svg +15 -2
- package/docs/skills.svg +15 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/herdr-plugin/restore-plans.ts +56 -6
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
- package/kit/hooks/hooks/merge-guard/decision.ts +3 -1
- package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
- package/kit/hooks/hooks/merge-guard/run.ts +6 -3
- package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
- package/kit/hooks/hooks/model-log/transcript.ts +4 -7
- package/kit/hooks/hooks/open-question/record.ts +4 -3
- package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
- package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
- package/kit/hooks/hooks/shared/transcript.ts +7 -4
- package/kit/hooks/hooks/task-hint/decision.ts +3 -2
- package/kit/hooks/hooks/task-hint/run.ts +2 -4
- package/kit/rules/rules/kit/pull-requests.md +1 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +22 -9
- package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
- package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +5 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +19 -0
- package/src/check/command.ts +1 -1
- package/src/check/file-kinds.ts +10 -8
- package/src/check/parallel-report.ts +10 -3
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +17 -5
- package/src/check/skill-budgets.ts +49 -19
- package/src/claude/paths.ts +12 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +27 -0
- package/src/context/fill.ts +2 -1
- package/src/context/log-vocabulary.ts +22 -0
- package/src/context/session-log.ts +2 -1
- package/src/lane-prompt/build.ts +10 -5
- package/src/lane-prompt/command.ts +6 -1
- package/src/lane-prompt/input.ts +4 -4
- package/src/lane-prompt/texts.ts +18 -10
- package/src/plan-contract/command.ts +69 -0
- package/src/plan-contract/contract.ts +144 -0
- package/src/plan-format/dependencies.ts +1 -4
- package/src/plan-format/index.ts +4 -0
- package/src/plan-format/lanes.ts +11 -0
- package/src/plan-format/markdown.ts +6 -5
- package/src/plan-format/model-table.ts +27 -0
- package/src/plans/activity-read.ts +98 -0
- package/src/plans/ask-coordinator.ts +20 -11
- package/src/plans/layout.ts +49 -4
- package/src/plans/live.ts +17 -4
- package/src/plans/plan-panes.ts +25 -5
- package/src/plans/snapshot.ts +18 -8
- package/src/plans/subagents-read.ts +4 -3
- package/src/plans/subagents.ts +2 -1
- package/src/plans/texts.ts +27 -1
- package/src/settled/send.ts +80 -4
- package/src/shared/parse-json.ts +8 -0
- package/src/sync/command.ts +17 -10
- package/src/sync/foreign-skills.ts +40 -0
- package/src/sync/global-kit.ts +31 -21
- package/src/sync/init.ts +2 -2
- package/src/sync/kit-exclude.ts +39 -0
- package/src/sync/plan-file.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/report.ts +37 -2
- package/src/sync/sync-result.ts +5 -2
- package/src/sync/sync.ts +13 -5
- package/src/task-signals/claude-signals.ts +6 -5
- package/src/watch/command.ts +56 -17
- package/src/watch/lane-message.ts +1 -1
- package/src/watch/lane-progress.ts +51 -2
- package/src/watch/lane-report.ts +57 -2
- package/src/watch/lane-watch.ts +2 -8
- package/src/watch/task-parallel-touched.ts +9 -2
- package/src/watch/texts.ts +4 -0
package/src/config/schema.ts
CHANGED
|
@@ -46,6 +46,15 @@ export const isModelRole = (value: string): value is ModelRole =>
|
|
|
46
46
|
/** Lower-case letters and digits ending in a dash, so a prefixed name stays one plain path segment. */
|
|
47
47
|
export const SKILL_PREFIX = /^[a-z0-9]+-$/
|
|
48
48
|
|
|
49
|
+
/** What a global install, and a project that already has skills of kit names, puts before every kit name. */
|
|
50
|
+
export const DEFAULT_SKILL_PREFIX = 'jh-'
|
|
51
|
+
|
|
52
|
+
/** The skills every lane and the coordinator run on; `kit.exclude` cannot take them away. */
|
|
53
|
+
export const KIT_CORE: readonly string[] = ['orchestrate', 'start']
|
|
54
|
+
|
|
55
|
+
/** A kit skill or agent as the kit names it, before `skillPrefix`. */
|
|
56
|
+
const KIT_ENTRY_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
57
|
+
|
|
49
58
|
const FULL_MODEL_ID = /^claude-[a-z0-9][a-z0-9.-]*$/
|
|
50
59
|
|
|
51
60
|
const language = z.enum(Object.values(PLAN_LANGUAGE))
|
|
@@ -56,6 +65,13 @@ const modelId = z
|
|
|
56
65
|
|
|
57
66
|
const command = z.string().min(1)
|
|
58
67
|
|
|
68
|
+
const excludedKitEntry = z
|
|
69
|
+
.string()
|
|
70
|
+
.regex(KIT_ENTRY_NAME, 'a kit skill or agent name as the kit names it, e.g. devops')
|
|
71
|
+
.refine((name) => !KIT_CORE.includes(name), {
|
|
72
|
+
error: (issue) => `${issue.input} is the kit's core and cannot be excluded`,
|
|
73
|
+
})
|
|
74
|
+
|
|
59
75
|
const tokenCount = z.number().int().positive()
|
|
60
76
|
|
|
61
77
|
/**
|
|
@@ -360,6 +376,12 @@ const mergeSchema = z.strictObject({
|
|
|
360
376
|
* `--match-head-commit`. Empty means every merge waits for checks.
|
|
361
377
|
*/
|
|
362
378
|
withoutChecks: z.array(checkFreePattern).default([]),
|
|
379
|
+
/**
|
|
380
|
+
* Whether a lane opens every pull request as a draft (`gh pr create --draft`), so pushes while it is worked on start
|
|
381
|
+
* no workflow that skips drafts. `/orchestrate` runs `gh pr ready` before the merge and waits for the checks that
|
|
382
|
+
* start then; the merge guard never lets a draft through. Off opens pull requests ready, as before.
|
|
383
|
+
*/
|
|
384
|
+
openAsDraft: z.boolean().default(false),
|
|
363
385
|
})
|
|
364
386
|
|
|
365
387
|
const guardFields = z.strictObject({
|
|
@@ -480,6 +502,11 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
480
502
|
.union([z.literal(''), z.string().regex(SKILL_PREFIX, 'lower-case letters and digits ending in a dash, e.g. kit-')])
|
|
481
503
|
.default(''),
|
|
482
504
|
commands: commandsSchema.prefault({}),
|
|
505
|
+
/**
|
|
506
|
+
* Kit skills and agents `jh sync` leaves out, by the kit's name without `skillPrefix`; a name takes its skill and
|
|
507
|
+
* its agent alike. An unchanged copy already installed is removed, a changed one stays untracked.
|
|
508
|
+
*/
|
|
509
|
+
kit: z.strictObject({ exclude: z.array(excludedKitEntry).default([]) }).prefault({}),
|
|
483
510
|
/** How `/orchestrate` sets up a lane's worktree. */
|
|
484
511
|
lanes: z
|
|
485
512
|
.strictObject({
|
package/src/context/fill.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LOG_ENTRY_TYPE } from './log-vocabulary'
|
|
1
2
|
import { keptLogEntry } from './session-log'
|
|
2
3
|
|
|
3
4
|
/** How the context window of a model is assumed: by model ID, with a default for the models not listed. */
|
|
@@ -26,7 +27,7 @@ const count = (value: unknown): number => (typeof value === 'number' && Number.i
|
|
|
26
27
|
/** The answer of a main-chain `assistant` line; null for every other line, a sub-agent's answer and a synthetic one. */
|
|
27
28
|
const answerOf = (line: string): Answer | null => {
|
|
28
29
|
const entry = keptLogEntry(line)
|
|
29
|
-
if (!entry || entry.type !==
|
|
30
|
+
if (!entry || entry.type !== LOG_ENTRY_TYPE.assistant || entry.isSidechain === true) return null
|
|
30
31
|
const { message } = entry
|
|
31
32
|
if (!isRecord(message) || typeof message.model !== 'string' || message.model === SYNTHETIC_MODEL) return null
|
|
32
33
|
const { usage } = message
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The entry types of a Claude Code session log line that a reader tells apart; the log delivers them as strings. */
|
|
2
|
+
export const LOG_ENTRY_TYPE = { assistant: 'assistant' } as const
|
|
3
|
+
|
|
4
|
+
/** The block types inside an entry's `message.content`. */
|
|
5
|
+
export const CONTENT_BLOCK_TYPE = { text: 'text', toolUse: 'tool_use', toolResult: 'tool_result' } as const
|
|
6
|
+
|
|
7
|
+
/** The names of Claude Code's tools a reader looks for in a `tool_use` block. */
|
|
8
|
+
export const CLAUDE_TOOL = {
|
|
9
|
+
taskCreate: 'TaskCreate',
|
|
10
|
+
taskUpdate: 'TaskUpdate',
|
|
11
|
+
todoWrite: 'TodoWrite',
|
|
12
|
+
edit: 'Edit',
|
|
13
|
+
write: 'Write',
|
|
14
|
+
multiEdit: 'MultiEdit',
|
|
15
|
+
notebookEdit: 'NotebookEdit',
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What every assistant entry's raw line holds, as the log writes it without spaces: a text search for it finds the
|
|
20
|
+
* answers of a log without parsing every line.
|
|
21
|
+
*/
|
|
22
|
+
export const ASSISTANT_ENTRY_MARK = `"type":"${LOG_ENTRY_TYPE.assistant}"`
|
|
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY } from '../claude/paths'
|
|
5
5
|
import { boundedMemo } from '../shared/bounded-memo'
|
|
6
6
|
import { isRecord } from '../shared/is-record'
|
|
7
|
+
import { ASSISTANT_ENTRY_MARK } from './log-vocabulary'
|
|
7
8
|
|
|
8
9
|
/** A tail read before: the file as it was (inode, size, modification time), its lines and the byte offset each starts at. */
|
|
9
10
|
type FollowedTail = { inode: number; size: number; modifiedAt: number; lines: string[]; starts: number[] }
|
|
@@ -159,7 +160,7 @@ export const sessionLog = (
|
|
|
159
160
|
|
|
160
161
|
export type AgentSession = { session: string | null; cwd: string | null }
|
|
161
162
|
|
|
162
|
-
const hasAnswer = (lines: readonly string[]): boolean => lines.some((line) => line.includes(
|
|
163
|
+
const hasAnswer = (lines: readonly string[]): boolean => lines.some((line) => line.includes(ASSISTANT_ENTRY_MARK))
|
|
163
164
|
|
|
164
165
|
/** Claude Code names a project directory after the working directory, `/` and `.` turned into `-`. */
|
|
165
166
|
export const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
|
package/src/lane-prompt/build.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type LanePromptInput = {
|
|
|
19
19
|
plans: LanePlan[]
|
|
20
20
|
port: number | null
|
|
21
21
|
handover: string | null
|
|
22
|
+
/** The project's `skillPrefix` (`''` for none), put before the name of the kit's skill the start names. */
|
|
23
|
+
skillPrefix: string
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
const writesText = (language: PlanLanguage, texts: LanePromptTexts, writes: string[] | null): string => {
|
|
@@ -35,11 +37,12 @@ const planLines = (language: PlanLanguage, texts: LanePromptTexts, plan: LanePla
|
|
|
35
37
|
...plan.phases.map((phase) => ` - ${phaseText(language, texts, phase)}`),
|
|
36
38
|
]
|
|
37
39
|
|
|
38
|
-
const ruleLines = (input: LanePromptInput, texts: LanePromptTexts): string[] => [
|
|
39
|
-
input.port === null ? texts.noPort : texts.port(input.port),
|
|
40
|
+
const ruleLines = (input: LanePromptInput, texts: LanePromptTexts, implementSkill: string): string[] => [
|
|
41
|
+
input.port === null ? texts.noPort : texts.port(input.port, implementSkill),
|
|
40
42
|
texts.blocking,
|
|
41
43
|
texts.mustNot,
|
|
42
44
|
texts.commit,
|
|
45
|
+
texts.taskSignals,
|
|
43
46
|
texts.report(laneReportFile(input.lane)),
|
|
44
47
|
texts.textBlock,
|
|
45
48
|
texts.secret,
|
|
@@ -51,11 +54,13 @@ const ruleLines = (input: LanePromptInput, texts: LanePromptTexts): string[] =>
|
|
|
51
54
|
/**
|
|
52
55
|
* The prompt that starts a lane, in the plan language: a first line that names the lane (the words the ask hint hook
|
|
53
56
|
* and the project's lane exception recognise), the plans with their phases as `Phase P<n>` and each phase's write
|
|
54
|
-
* set, the rules of `/orchestrate` § 1
|
|
57
|
+
* set, the rules of `/orchestrate` § 1 and the lane contract (report each task, the report file, questions as text blocks,
|
|
58
|
+
* git through the coordinator), and the start: the phases built with `/implement` or another skill, or the
|
|
55
59
|
* hand-over file after a restart.
|
|
56
60
|
*/
|
|
57
61
|
export const buildLanePrompt = (input: LanePromptInput): string => {
|
|
58
62
|
const texts = LANE_PROMPT_TEXTS[input.language]
|
|
63
|
+
const implementSkill = `/${input.skillPrefix}implement`
|
|
59
64
|
return [
|
|
60
65
|
`${LANE_PROMPT_OPENING[input.language]} ${input.lane}. ${texts.answersTo}`,
|
|
61
66
|
'',
|
|
@@ -66,9 +71,9 @@ export const buildLanePrompt = (input: LanePromptInput): string => {
|
|
|
66
71
|
texts.editInside,
|
|
67
72
|
texts.writeTools,
|
|
68
73
|
'',
|
|
69
|
-
...ruleLines(input, texts).map((line) => `- ${line}`),
|
|
74
|
+
...ruleLines(input, texts, implementSkill).map((line) => `- ${line}`),
|
|
70
75
|
'',
|
|
71
|
-
input.handover === null ? texts.start : texts.restart(input.handover),
|
|
76
|
+
input.handover === null ? texts.start(implementSkill) : texts.restart(input.handover),
|
|
72
77
|
'',
|
|
73
78
|
].join('\n')
|
|
74
79
|
}
|
|
@@ -46,7 +46,12 @@ const promptSourcesOf = (options: LanePromptOptions, cwd: string): PromptSources
|
|
|
46
46
|
if (board.notice) console.error(board.notice)
|
|
47
47
|
const directory = dirname(boardRepoPathOf(root, settings, board.file))
|
|
48
48
|
return {
|
|
49
|
-
request: {
|
|
49
|
+
request: {
|
|
50
|
+
lane: options.lane,
|
|
51
|
+
handover: options.handover,
|
|
52
|
+
language: settings.planLanguage,
|
|
53
|
+
skillPrefix: settings.skillPrefix,
|
|
54
|
+
},
|
|
50
55
|
board: { text: readFileSync(board.file, 'utf8'), directory, path: relative(cwd, board.file) },
|
|
51
56
|
readPlan: readFirstIn([root, checkout]),
|
|
52
57
|
readLanePlan: readFirstIn([worktreeOf(root, settings, options.lane)]),
|
package/src/lane-prompt/input.ts
CHANGED
|
@@ -5,8 +5,8 @@ import { isPhaseFinished } from '../plan-format/progress'
|
|
|
5
5
|
import type { PlanLanguage } from '../vocabulary'
|
|
6
6
|
import type { LanePhase, LanePlan, LanePromptInput } from './build'
|
|
7
7
|
|
|
8
|
-
/** The lane to prompt, the hand-over file of a restart (null for a fresh start)
|
|
9
|
-
export type LanePromptRequest = { lane: string; handover: string | null; language: PlanLanguage }
|
|
8
|
+
/** The lane to prompt, the hand-over file of a restart (null for a fresh start), the plan language and the project's `skillPrefix`. */
|
|
9
|
+
export type LanePromptRequest = { lane: string; handover: string | null; language: PlanLanguage; skillPrefix: string }
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* The board's text, its directory from the repository root, which its plan links are relative to, and the path a
|
|
@@ -146,12 +146,12 @@ export const lanePromptInputOf = (
|
|
|
146
146
|
const found = plansOf(steps, { main: readPlan, lane: readLanePlan }, request.language)
|
|
147
147
|
if ('reason' in found) return refused(found.reason)
|
|
148
148
|
if (found.plans.length === 0) return refused(`every phase of lane ${request.lane} is finished`)
|
|
149
|
-
const { lane, handover, language } = request
|
|
149
|
+
const { lane, handover, language, skillPrefix } = request
|
|
150
150
|
const others = lanes.filter((candidate) => candidate.name !== lane && !candidate.closed)
|
|
151
151
|
const { plans, notices } = withoutContested(found.plans, lane, others, board.directory)
|
|
152
152
|
if (plans.length === 0) {
|
|
153
153
|
return refused(`every open phase of lane ${lane} is also named by another lane: ${notices.join('; ')}`)
|
|
154
154
|
}
|
|
155
|
-
const input = { language, lane, plans, port: sprintLane.port, handover }
|
|
155
|
+
const input = { language, lane, plans, port: sprintLane.port, handover, skillPrefix }
|
|
156
156
|
return notices.length === 0 ? { kind: 'prompt', input } : { kind: 'prompt', input, notices }
|
|
157
157
|
}
|
package/src/lane-prompt/texts.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
1
2
|
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -12,11 +13,12 @@ export type LanePromptTexts = {
|
|
|
12
13
|
wholeRepository: string
|
|
13
14
|
editInside: string
|
|
14
15
|
writeTools: string
|
|
15
|
-
port: (port: number) => string
|
|
16
|
+
port: (port: number, implementSkill: string) => string
|
|
16
17
|
noPort: string
|
|
17
18
|
blocking: string
|
|
18
19
|
mustNot: string
|
|
19
20
|
commit: string
|
|
21
|
+
taskSignals: string
|
|
20
22
|
report: (reportPath: string) => string
|
|
21
23
|
textBlock: string
|
|
22
24
|
secret: string
|
|
@@ -24,7 +26,7 @@ export type LanePromptTexts = {
|
|
|
24
26
|
approvalGate: string
|
|
25
27
|
helper: (helperName: string) => string
|
|
26
28
|
helperPurpose: string
|
|
27
|
-
start: string
|
|
29
|
+
start: (implementSkill: string) => string
|
|
28
30
|
restart: (handoverPath: string) => string
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -38,15 +40,17 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
|
|
|
38
40
|
'Nothing outside the Writes set of your phases is edited, except the task lines of your phases in the plan: tick a finished one right away, by an Edit of exactly that line.',
|
|
39
41
|
writeTools:
|
|
40
42
|
'Write code and tests with Write and Edit, never through a here-document or a script in Bash: such a call shows the reviewer no diff, and an unquoted or unreadable one can stop at a guard.',
|
|
41
|
-
port: (port) =>
|
|
42
|
-
`Port ${port}: the coordinator runs your dev server in its own pane; look at your visible changes there (
|
|
43
|
+
port: (port, implementSkill) =>
|
|
44
|
+
`Port ${port}: the coordinator runs your dev server in its own pane; look at your visible changes there (\`${implementSkill}\` Step 4b) and never start a server in your own shell.`,
|
|
43
45
|
noPort:
|
|
44
46
|
'No port: this lane has no dev server of its own. Never start a server in your own shell; ask the coordinator when you need one.',
|
|
45
47
|
blocking:
|
|
46
48
|
'Mark every question of yours `blocking` or `not blocking`. A blocking one stops the step it decides; for a not blocking one leave a `TODO` at its spot and work on, building neither your recommendation nor another option as decided.',
|
|
47
49
|
mustNot:
|
|
48
50
|
'Without the user’s approval you push no database migration and write nothing against a shared environment (stage, production); you never run `git reset` or `git stash` on shared refs.',
|
|
49
|
-
commit:
|
|
51
|
+
commit:
|
|
52
|
+
'Ask before every commit (`/pre-commit` is the way where it is installed); git (commit, push, PR, merge) goes only through the coordinator and only with approval.',
|
|
53
|
+
taskSignals: `Whatever skill builds a task, report it: \`${CLI_NAME} task start "<task>"\` before and \`${CLI_NAME} task done\` after every task. The herd shows the running task from that signal, and the watcher sees a standstill by it.`,
|
|
50
54
|
report: (reportPath) =>
|
|
51
55
|
`The report at the end of each step goes to \`${reportPath}\` in the worktree, in the short form: one line of diff stat, one line per check with its numbers, open points, questions as a text block under the heading \`## Questions\` (whatever the report’s language: the watcher finds the section by that word and shows it whole); everything else is in the plan, by reference.`,
|
|
52
56
|
textBlock:
|
|
@@ -60,7 +64,8 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
|
|
|
60
64
|
helper: (helperName) =>
|
|
61
65
|
`A helper agent in a pane of your own (a measurement, a probe session) is announced to the coordinator before it starts, as a question at the end of your turn: what it does, how long, and which temporary settings it uses. Once approved, name it \`${helperName}\` (\`herdr agent rename\`), so the watcher watches it with your lane, and close it yourself when it is done: \`/exit\`, then \`herdr pane close\`, and remove every temporary file it needed.`,
|
|
62
66
|
helperPurpose: '<purpose>',
|
|
63
|
-
start:
|
|
67
|
+
start: (implementSkill) =>
|
|
68
|
+
`Start: build your phases in the order above, with \`${implementSkill}\` or a skill of your choice; the rules above hold for every one.`,
|
|
64
69
|
restart: (handoverPath) => `Start: read \`${handoverPath}\` first and continue with the next step it names.`,
|
|
65
70
|
},
|
|
66
71
|
[PLAN_LANGUAGE.de]: {
|
|
@@ -72,15 +77,17 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
|
|
|
72
77
|
'Außerhalb der Schreibt-Menge deiner Phasen änderst du nichts, außer den Aufgabenzeilen deiner Phasen im Plan: eine erledigte hakst du sofort ab, per Edit auf genau diese Zeile.',
|
|
73
78
|
writeTools:
|
|
74
79
|
'Code und Tests schreibst du mit Write und Edit, nie per Heredoc oder Skript in Bash: ein solcher Aufruf zeigt dem Prüfer keinen Diff, und ein ungequoteter oder unlesbarer kann an einem Wächter hängen bleiben.',
|
|
75
|
-
port: (port) =>
|
|
76
|
-
`Port ${port}: Der Koordinator betreibt deinen Dev-Server in seinem eigenen Pane; deine sichtbaren Änderungen siehst du dort an (
|
|
80
|
+
port: (port, implementSkill) =>
|
|
81
|
+
`Port ${port}: Der Koordinator betreibt deinen Dev-Server in seinem eigenen Pane; deine sichtbaren Änderungen siehst du dort an (\`${implementSkill}\` Step 4b) und startest nie einen Server in deiner eigenen Shell.`,
|
|
77
82
|
noPort:
|
|
78
83
|
'Kein Port: Diese Spur hat keinen eigenen Dev-Server. Starte nie einen Server in deiner eigenen Shell; brauchst du einen, frag den Koordinator.',
|
|
79
84
|
blocking:
|
|
80
85
|
'Markiere jede deiner Fragen mit `blocking` oder `not blocking`. Eine blockierende hält den Schritt an, den sie entscheidet; bei einer nicht blockierenden lässt du an ihrer Stelle ein `TODO` und arbeitest weiter, ohne deine Empfehlung oder eine andere Option als entschieden zu bauen.',
|
|
81
86
|
mustNot:
|
|
82
87
|
'Ohne Freigabe des Users pushst du keine Datenbank-Migration und schreibst nichts gegen eine geteilte Umgebung (Stage, Produktion); `git reset` und `git stash` auf geteilten Refs führst du nie aus.',
|
|
83
|
-
commit:
|
|
88
|
+
commit:
|
|
89
|
+
'Vor jedem Commit fragst du (`/pre-commit` ist der Weg, wo es installiert ist); Git (Commit, Push, PR, Merge) läuft nur über den Koordinator und nur mit Freigabe.',
|
|
90
|
+
taskSignals: `Gleich mit welchem Skill eine Aufgabe gebaut wird, meldest du sie: \`${CLI_NAME} task start "<aufgabe>"\` vor und \`${CLI_NAME} task done\` nach jeder Aufgabe. Die Herde zeigt die laufende Aufgabe an diesem Signal, und der Watcher erkennt daran einen Stillstand.`,
|
|
84
91
|
report: (reportPath) =>
|
|
85
92
|
`Den Bericht am Ende jedes Schritts schreibst du nach \`${reportPath}\` im Worktree, in der Kurzform: eine Zeile Diff-Stat, eine Zeile je Check mit seinen Zahlen, offene Punkte, Fragen als Textblock unter der Überschrift \`## Questions\` (in jeder Berichtssprache: der Watcher findet den Abschnitt an diesem Wort und zeigt ihn ganz); alles andere steht im Plan, per Verweis.`,
|
|
86
93
|
textBlock:
|
|
@@ -94,7 +101,8 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
|
|
|
94
101
|
helper: (helperName) =>
|
|
95
102
|
`Einen Helfer-Agenten in einem eigenen Pane (eine Messung, eine Probesitzung) meldest du dem Koordinator an, bevor er startet, als Frage am Ende deines Turns: was er tut, wie lange, und welche temporären Einstellungen er nutzt. Nach der Freigabe benennst du ihn \`${helperName}\` (\`herdr agent rename\`), damit der Watcher ihn mit deiner Spur beobachtet, und schließt ihn selbst, wenn er fertig ist: \`/exit\`, dann \`herdr pane close\`, und entferne jede temporäre Datei, die er brauchte.`,
|
|
96
103
|
helperPurpose: '<zweck>',
|
|
97
|
-
start:
|
|
104
|
+
start: (implementSkill) =>
|
|
105
|
+
`Start: Baue deine Phasen in der Reihenfolge oben, mit \`${implementSkill}\` oder einem Skill deiner Wahl; die Regeln oben gelten für jeden.`,
|
|
98
106
|
restart: (handoverPath) =>
|
|
99
107
|
`Start: Lies zuerst \`${handoverPath}\` und mach mit dem nächsten Schritt weiter, den sie nennt.`,
|
|
100
108
|
},
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { findProjectRoot } from '../config/location'
|
|
5
|
+
import { readConfig } from '../config/read-config'
|
|
6
|
+
import { asksForHelp } from '../preview/options'
|
|
7
|
+
import { CLI_NAME } from '../version/release'
|
|
8
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
9
|
+
import { contractOf, type PartFinding, type PlanContract } from './contract'
|
|
10
|
+
|
|
11
|
+
const CHECK_SUBCOMMAND = 'check'
|
|
12
|
+
|
|
13
|
+
/** Exit codes of `jh plan check`: the contract is met, a part has a gap, the command line or the file is wrong. */
|
|
14
|
+
export const PLAN_CHECK_EXIT = { met: 0, gaps: 1, error: 2 } as const
|
|
15
|
+
|
|
16
|
+
export const USAGE = `usage: ${CLI_NAME} plan ${CHECK_SUBCOMMAND} <plan.md>
|
|
17
|
+
|
|
18
|
+
Checks one plan against the plan contract: phases, tasks in every phase, a model for every phase, a dependency
|
|
19
|
+
row for every phase (from two phases on). One line per part, \`file:line\` where a part is incomplete.
|
|
20
|
+
The plan language comes from .jackherd/config.json.
|
|
21
|
+
Exit 0 met, 1 with gaps, 2 for a wrong command line or an unreadable file.`
|
|
22
|
+
|
|
23
|
+
const where = (file: string, line: number | null): string => (line === null ? '' : ` ${file}:${line}`)
|
|
24
|
+
|
|
25
|
+
/** One line per part: `model: malformed plan.md:12 no model for P2`. */
|
|
26
|
+
export const contractLines = (file: string, { parts }: PlanContract): string[] =>
|
|
27
|
+
parts.map(({ part, status, line, message }: PartFinding) => `${part}: ${status}${where(file, line)} ${message}`)
|
|
28
|
+
|
|
29
|
+
type LanguageResult = { language: PlanLanguage } | { error: string }
|
|
30
|
+
|
|
31
|
+
const languageOf = (home: string): LanguageResult => {
|
|
32
|
+
try {
|
|
33
|
+
const root = findProjectRoot(process.cwd(), home, existsSync) ?? process.cwd()
|
|
34
|
+
return { language: readConfig([root], home).planLanguage }
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const readPlan = (file: string): string | null => {
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(resolve(process.cwd(), file), 'utf8')
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fail = (message: string): number => {
|
|
49
|
+
console.error(message)
|
|
50
|
+
return PLAN_CHECK_EXIT.error
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const run = (args: string[], home: string = homedir()): number => {
|
|
54
|
+
if (asksForHelp(args)) {
|
|
55
|
+
console.log(USAGE)
|
|
56
|
+
return PLAN_CHECK_EXIT.met
|
|
57
|
+
}
|
|
58
|
+
const [subcommand, file, ...rest] = args
|
|
59
|
+
if (subcommand !== CHECK_SUBCOMMAND)
|
|
60
|
+
return fail(subcommand === undefined ? USAGE : `unknown subcommand ${subcommand}\n${USAGE}`)
|
|
61
|
+
if (file === undefined || rest.length > 0) return fail(USAGE)
|
|
62
|
+
const markdown = readPlan(file)
|
|
63
|
+
if (markdown === null) return fail(`cannot read ${file}\n${USAGE}`)
|
|
64
|
+
const result = languageOf(home)
|
|
65
|
+
if ('error' in result) return fail(result.error)
|
|
66
|
+
const contract = contractOf(markdown, result.language)
|
|
67
|
+
for (const line of contractLines(file, contract)) console.log(line)
|
|
68
|
+
return contract.met ? PLAN_CHECK_EXIT.met : PLAN_CHECK_EXIT.gaps
|
|
69
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ModelRow,
|
|
3
|
+
NO_DEPENDENCY_TABLE,
|
|
4
|
+
type PhaseHeading,
|
|
5
|
+
parseDependencyTable,
|
|
6
|
+
parseModelTable,
|
|
7
|
+
parsePlanPhases,
|
|
8
|
+
phaseCount,
|
|
9
|
+
phaseHeadings,
|
|
10
|
+
phaseLabels,
|
|
11
|
+
} from '../plan-format'
|
|
12
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
13
|
+
|
|
14
|
+
/** The four parts of the plan contract. */
|
|
15
|
+
export const CONTRACT_PART = {
|
|
16
|
+
phases: 'phases',
|
|
17
|
+
tasks: 'tasks',
|
|
18
|
+
model: 'model',
|
|
19
|
+
dependencies: 'dependencies',
|
|
20
|
+
} as const
|
|
21
|
+
|
|
22
|
+
export type ContractPart = (typeof CONTRACT_PART)[keyof typeof CONTRACT_PART]
|
|
23
|
+
|
|
24
|
+
/** What the plan holds of a part: all of it, none of it, or something the readers cannot use. */
|
|
25
|
+
export const PART_STATUS = { present: 'present', missing: 'missing', malformed: 'malformed' } as const
|
|
26
|
+
|
|
27
|
+
export type PartStatus = (typeof PART_STATUS)[keyof typeof PART_STATUS]
|
|
28
|
+
|
|
29
|
+
/** `line` is the 1-based line of the first problem, or of the part's first sign; null when the plan shows none. */
|
|
30
|
+
export type PartFinding = { part: ContractPart; status: PartStatus; line: number | null; message: string }
|
|
31
|
+
|
|
32
|
+
/** `met` is false when a part is malformed, or phases, tasks or the model table are missing; a missing dependency table is a note. */
|
|
33
|
+
export type PlanContract = { parts: PartFinding[]; met: boolean }
|
|
34
|
+
|
|
35
|
+
/** Fewer phases than this need no dependency table: there is nothing to put side by side. */
|
|
36
|
+
const PHASES_NEEDING_DEPENDENCIES = 2
|
|
37
|
+
|
|
38
|
+
const SUB_PHASE_SUFFIX = /[a-z]$/
|
|
39
|
+
const NO_MODEL_MARK = /^[—–-]/
|
|
40
|
+
|
|
41
|
+
/** The phase a sub-phase belongs to (`3b` is `3`); a phase without a letter is its own base. */
|
|
42
|
+
const baseOf = (id: string): string => id.replace(SUB_PHASE_SUFFIX, '')
|
|
43
|
+
|
|
44
|
+
const finding = (part: ContractPart, status: PartStatus, line: number | null, message: string): PartFinding => ({
|
|
45
|
+
part,
|
|
46
|
+
status,
|
|
47
|
+
line,
|
|
48
|
+
message,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const uniqueHeadings = (headings: readonly PhaseHeading[]): PhaseHeading[] =>
|
|
52
|
+
headings.filter(({ id }, index) => headings.findIndex((other) => other.id === id) === index)
|
|
53
|
+
|
|
54
|
+
const phasesPart = (headings: readonly PhaseHeading[]): PartFinding =>
|
|
55
|
+
headings.length === 0
|
|
56
|
+
? finding(CONTRACT_PART.phases, PART_STATUS.missing, null, 'no phase heading')
|
|
57
|
+
: finding(CONTRACT_PART.phases, PART_STATUS.present, headings[0].line, phaseCount(headings.length))
|
|
58
|
+
|
|
59
|
+
/** Present when no phase lacks the part (`ok`), else malformed at the first phase that does, naming them all. */
|
|
60
|
+
const coverage = (
|
|
61
|
+
part: ContractPart,
|
|
62
|
+
without: readonly PhaseHeading[],
|
|
63
|
+
ok: { line: number | null; message: string },
|
|
64
|
+
problem: string,
|
|
65
|
+
): PartFinding =>
|
|
66
|
+
without.length === 0
|
|
67
|
+
? finding(part, PART_STATUS.present, ok.line, ok.message)
|
|
68
|
+
: finding(
|
|
69
|
+
part,
|
|
70
|
+
PART_STATUS.malformed,
|
|
71
|
+
without[0].line,
|
|
72
|
+
`${problem} ${phaseLabels(
|
|
73
|
+
without.map(({ id }) => id),
|
|
74
|
+
', ',
|
|
75
|
+
)}`,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const tasksPart = (markdown: string, language: PlanLanguage, headings: readonly PhaseHeading[]): PartFinding => {
|
|
79
|
+
if (headings.length === 0) return finding(CONTRACT_PART.tasks, PART_STATUS.missing, null, 'no phase to hold tasks')
|
|
80
|
+
const empty = new Set(
|
|
81
|
+
parsePlanPhases(markdown, language)
|
|
82
|
+
.filter(({ items }) => items.length === 0)
|
|
83
|
+
.map(({ id }) => id),
|
|
84
|
+
)
|
|
85
|
+
const without = headings.filter(({ id }) => empty.has(id))
|
|
86
|
+
return coverage(
|
|
87
|
+
CONTRACT_PART.tasks,
|
|
88
|
+
without,
|
|
89
|
+
{ line: headings[0].line, message: 'every phase has tasks' },
|
|
90
|
+
'no checkbox in',
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A row names a model, or marks the phase as one that needs none (`—`, a phase only a person does). */
|
|
95
|
+
const isDecided = ({ models, cell }: ModelRow): boolean => models.length > 0 || NO_MODEL_MARK.test(cell)
|
|
96
|
+
|
|
97
|
+
/** A phase is covered by a listed id of its own or of its base phase. */
|
|
98
|
+
const lacks = (headings: readonly PhaseHeading[], listed: ReadonlySet<string>): PhaseHeading[] =>
|
|
99
|
+
headings.filter(({ id }) => !listed.has(id) && !listed.has(baseOf(id)))
|
|
100
|
+
|
|
101
|
+
const modelPart = (markdown: string, language: PlanLanguage, headings: readonly PhaseHeading[]): PartFinding => {
|
|
102
|
+
const rows = parseModelTable(markdown, language)
|
|
103
|
+
if (rows === null) return finding(CONTRACT_PART.model, PART_STATUS.missing, null, 'no model table')
|
|
104
|
+
const modelled = new Set(rows.filter(isDecided).map(({ id }) => id))
|
|
105
|
+
return coverage(
|
|
106
|
+
CONTRACT_PART.model,
|
|
107
|
+
lacks(headings, modelled),
|
|
108
|
+
{ line: rows[0]?.line ?? null, message: 'every phase has a model' },
|
|
109
|
+
'no model for',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const dependenciesPart = (markdown: string, language: PlanLanguage, headings: readonly PhaseHeading[]): PartFinding => {
|
|
114
|
+
if (headings.length < PHASES_NEEDING_DEPENDENCIES)
|
|
115
|
+
return finding(CONTRACT_PART.dependencies, PART_STATUS.present, null, 'not needed with fewer than two phases')
|
|
116
|
+
const table = parseDependencyTable(markdown, language)
|
|
117
|
+
if (table === null) return finding(CONTRACT_PART.dependencies, PART_STATUS.missing, null, NO_DEPENDENCY_TABLE)
|
|
118
|
+
return coverage(
|
|
119
|
+
CONTRACT_PART.dependencies,
|
|
120
|
+
lacks(headings, new Set(table.map(({ id }) => id))),
|
|
121
|
+
{ line: null, message: 'every phase has a row' },
|
|
122
|
+
'no row for',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A missing dependency table is a note, not a gap: the phases then run one after another. */
|
|
127
|
+
export const isGap = ({ part, status }: PartFinding): boolean =>
|
|
128
|
+
status === PART_STATUS.malformed || (status === PART_STATUS.missing && part !== CONTRACT_PART.dependencies)
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* What a plan holds of the contract the herd, the watcher, `jh check` and a lane start read: phase headings,
|
|
132
|
+
* checkboxes in every phase, a recognised model for every phase, and (from two phases on) a dependency row for every
|
|
133
|
+
* phase. Built on the parsers those readers use, so a part is present exactly when they find it.
|
|
134
|
+
*/
|
|
135
|
+
export const contractOf = (markdown: string, language: PlanLanguage): PlanContract => {
|
|
136
|
+
const headings = uniqueHeadings(phaseHeadings(markdown, language))
|
|
137
|
+
const parts = [
|
|
138
|
+
phasesPart(headings),
|
|
139
|
+
tasksPart(markdown, language, headings),
|
|
140
|
+
modelPart(markdown, language, headings),
|
|
141
|
+
dependenciesPart(markdown, language, headings),
|
|
142
|
+
]
|
|
143
|
+
return { parts, met: !parts.some(isGap) }
|
|
144
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
-
import { PHASE_ID_PREFIX, phaseLabels, spannedPhaseIds } from './lanes'
|
|
2
|
+
import { PHASE_ID_PREFIX, phaseIdOf, phaseLabels, spannedPhaseIds } from './lanes'
|
|
3
3
|
import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
|
|
4
4
|
import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
|
|
5
5
|
import { isPhaseFinished } from './progress'
|
|
@@ -30,7 +30,6 @@ export type PhaseWithoutRow = PhaseHeading
|
|
|
30
30
|
type Candidate = { id: string; writes: string[]; serialMarkers: string[]; releases: string[] }
|
|
31
31
|
|
|
32
32
|
const ID = '(\\d+[a-z]?)'
|
|
33
|
-
const PHASE_ID = new RegExp(`^\\s*${PHASE_ID_PREFIX}?${ID}\\b`, 'i')
|
|
34
33
|
const PHASE_RANGE = new RegExp(`^${PHASE_ID_PREFIX}?(\\d+)\\s*[–-]\\s*${PHASE_ID_PREFIX}?(\\d+)$`, 'i')
|
|
35
34
|
const BACKTICKED = /`([^`]+)`/g
|
|
36
35
|
const NO_VALUE = /^[—–-]?$/
|
|
@@ -41,8 +40,6 @@ const PACKAGE_SCOPE = /^@[^/]+\//
|
|
|
41
40
|
/** The files a release of the package in their directory changes: the changelog and the version in the manifest. */
|
|
42
41
|
const RELEASE_FILE = /(?:^|\/)(?:CHANGELOG\.md|package\.json)$/
|
|
43
42
|
|
|
44
|
-
const phaseIdOf = (cell: string): string | null => PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
|
|
45
|
-
|
|
46
43
|
const splitOutsideParentheses = (cell: string, separator: string): string[] => {
|
|
47
44
|
const entries: string[] = []
|
|
48
45
|
let depth = 0
|
package/src/plan-format/index.ts
CHANGED
|
@@ -18,9 +18,12 @@ export * from './drift'
|
|
|
18
18
|
export * from './header'
|
|
19
19
|
export {
|
|
20
20
|
laneNameOf,
|
|
21
|
+
NO_DEPENDENCY_TABLE,
|
|
21
22
|
PHASE_ID_PREFIX,
|
|
22
23
|
type PlanStep,
|
|
23
24
|
parseSprintLanes,
|
|
25
|
+
phaseCount,
|
|
26
|
+
phaseIdOf,
|
|
24
27
|
phaseIdsOfCell,
|
|
25
28
|
phaseLabel,
|
|
26
29
|
phaseLabels,
|
|
@@ -30,6 +33,7 @@ export {
|
|
|
30
33
|
withoutRepeats,
|
|
31
34
|
} from './lanes'
|
|
32
35
|
export * from './model-column'
|
|
36
|
+
export * from './model-table'
|
|
33
37
|
export * from './phase-tally'
|
|
34
38
|
export * from './phases'
|
|
35
39
|
export * from './plan-structure'
|
package/src/plan-format/lanes.ts
CHANGED
|
@@ -29,6 +29,17 @@ 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
|
+
const LEADING_PHASE_ID = new RegExp(`^\\s*${PHASE_ID_PREFIX}?(\\d+[a-z]?)\\b`, 'i')
|
|
33
|
+
|
|
34
|
+
/** The phase a table's first cell names (`1 — Title`, `P1`, `10c`), lower-cased; null when it names none. */
|
|
35
|
+
export const phaseIdOf = (cell: string): string | null => LEADING_PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
|
|
36
|
+
|
|
37
|
+
/** What a report says when the plan has no dependency table. */
|
|
38
|
+
export const NO_DEPENDENCY_TABLE = 'no dependency table: phases run one after another'
|
|
39
|
+
|
|
40
|
+
/** `1 phase`, `3 phases`. */
|
|
41
|
+
export const phaseCount = (count: number): string => `${count} ${count === 1 ? 'phase' : 'phases'}`
|
|
42
|
+
|
|
32
43
|
/** A phase as plans and reports name it: `P4`. */
|
|
33
44
|
export const phaseLabel = (id: string): string => `${PHASE_ID_PREFIX}${id}`
|
|
34
45
|
|
|
@@ -56,13 +56,14 @@ export const firstHeading = (markdown: string, fallback: string): string =>
|
|
|
56
56
|
|
|
57
57
|
const HEADING = /^(#{2,4})\s+(.*?)\s*$/
|
|
58
58
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
/** The 0-based line index of the first level 2 to 4 heading whose text starts with `headingText` (a suffix such as `(PFLICHT ab 3 Phasen)` is allowed); -1 when there is none. */
|
|
60
|
+
export const subsectionStart = (markdown: string, headingText: string): number =>
|
|
61
|
+
markdown.split('\n').findIndex((line) => HEADING.exec(line)?.[2].startsWith(headingText))
|
|
62
|
+
|
|
63
|
+
/** The lines under that heading (see `subsectionStart`), up to the next heading of the same or a higher level. */
|
|
63
64
|
export const subsectionLines = (markdown: string, headingText: string): string[] => {
|
|
64
65
|
const lines = markdown.split('\n')
|
|
65
|
-
const start =
|
|
66
|
+
const start = subsectionStart(markdown, headingText)
|
|
66
67
|
if (start === -1) return []
|
|
67
68
|
const level = HEADING.exec(lines[start])?.[1].length ?? 2
|
|
68
69
|
const rest = lines.slice(start + 1)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
+
import { phaseIdOf } from './lanes'
|
|
3
|
+
import { firstTableRows, subsectionLines, subsectionStart } from './markdown'
|
|
4
|
+
import { type ColumnModel, columnModels } from './model-column'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A row of the model table: the phase it names, the models its model cell names (none for a dash), the model cell as
|
|
8
|
+
* written, and the row's 1-based line.
|
|
9
|
+
*/
|
|
10
|
+
export type ModelRow = { id: string; models: ColumnModel[]; cell: string; line: number }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The rows of the plan's model table (`### Model-Empfehlungen pro Phase`): the phase from the first cell (`1 — Title`,
|
|
14
|
+
* `P1`, `10c`), the models from the second, read like a board's model column. A row whose first cell names no phase is
|
|
15
|
+
* skipped; null when the plan has no such section or the section holds no table.
|
|
16
|
+
*/
|
|
17
|
+
export const parseModelTable = (markdown: string, language: PlanLanguage): ModelRow[] | null => {
|
|
18
|
+
const heading = PLAN_VOCABULARY[language].sections.modelRecommendations
|
|
19
|
+
const rows = firstTableRows(subsectionLines(markdown, heading))
|
|
20
|
+
if (rows.length === 0) return null
|
|
21
|
+
const firstBodyLine = subsectionStart(markdown, heading) + 2
|
|
22
|
+
return rows.flatMap(({ index, cells }) => {
|
|
23
|
+
const id = phaseIdOf(cells[0] ?? '')
|
|
24
|
+
const cell = cells[1] ?? ''
|
|
25
|
+
return id === null ? [] : [{ id, models: columnModels(cell), cell, line: firstBodyLine + index }]
|
|
26
|
+
})
|
|
27
|
+
}
|