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.
Files changed (84) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +27 -19
  3. package/assets/icons/skills.svg +1 -0
  4. package/dist/config.d.ts +9 -1
  5. package/dist/config.js +24 -1
  6. package/dist/plan-format.d.ts +24 -1
  7. package/dist/plan-format.js +25 -5
  8. package/docs/plan-contract.md +84 -0
  9. package/docs/skills-dark.svg +15 -2
  10. package/docs/skills.svg +15 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/herdr-plugin/restore-plans.ts +56 -6
  13. package/install.sh +1 -1
  14. package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
  15. package/kit/hooks/hooks/merge-guard/decision.ts +3 -1
  16. package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
  17. package/kit/hooks/hooks/merge-guard/run.ts +6 -3
  18. package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
  19. package/kit/hooks/hooks/model-log/transcript.ts +4 -7
  20. package/kit/hooks/hooks/open-question/record.ts +4 -3
  21. package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
  22. package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
  23. package/kit/hooks/hooks/shared/transcript.ts +7 -4
  24. package/kit/hooks/hooks/task-hint/decision.ts +3 -2
  25. package/kit/hooks/hooks/task-hint/run.ts +2 -4
  26. package/kit/rules/rules/kit/pull-requests.md +1 -0
  27. package/kit/workflow/skills/orchestrate/SKILL.md +22 -9
  28. package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
  29. package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
  30. package/kit/workflow/skills/orchestrate/references/plan-commit.md +5 -5
  31. package/package.json +1 -1
  32. package/schemas/jackherd.schema.json +19 -0
  33. package/src/check/command.ts +1 -1
  34. package/src/check/file-kinds.ts +10 -8
  35. package/src/check/parallel-report.ts +10 -3
  36. package/src/check/settings.ts +4 -0
  37. package/src/check/skill-budget-defaults.ts +17 -5
  38. package/src/check/skill-budgets.ts +49 -19
  39. package/src/claude/paths.ts +12 -0
  40. package/src/cli.ts +1 -0
  41. package/src/config/schema.ts +27 -0
  42. package/src/context/fill.ts +2 -1
  43. package/src/context/log-vocabulary.ts +22 -0
  44. package/src/context/session-log.ts +2 -1
  45. package/src/lane-prompt/build.ts +10 -5
  46. package/src/lane-prompt/command.ts +6 -1
  47. package/src/lane-prompt/input.ts +4 -4
  48. package/src/lane-prompt/texts.ts +18 -10
  49. package/src/plan-contract/command.ts +69 -0
  50. package/src/plan-contract/contract.ts +144 -0
  51. package/src/plan-format/dependencies.ts +1 -4
  52. package/src/plan-format/index.ts +4 -0
  53. package/src/plan-format/lanes.ts +11 -0
  54. package/src/plan-format/markdown.ts +6 -5
  55. package/src/plan-format/model-table.ts +27 -0
  56. package/src/plans/activity-read.ts +98 -0
  57. package/src/plans/ask-coordinator.ts +20 -11
  58. package/src/plans/layout.ts +49 -4
  59. package/src/plans/live.ts +17 -4
  60. package/src/plans/plan-panes.ts +25 -5
  61. package/src/plans/snapshot.ts +18 -8
  62. package/src/plans/subagents-read.ts +4 -3
  63. package/src/plans/subagents.ts +2 -1
  64. package/src/plans/texts.ts +27 -1
  65. package/src/settled/send.ts +80 -4
  66. package/src/shared/parse-json.ts +8 -0
  67. package/src/sync/command.ts +17 -10
  68. package/src/sync/foreign-skills.ts +40 -0
  69. package/src/sync/global-kit.ts +31 -21
  70. package/src/sync/init.ts +2 -2
  71. package/src/sync/kit-exclude.ts +39 -0
  72. package/src/sync/plan-file.ts +1 -1
  73. package/src/sync/render.ts +2 -1
  74. package/src/sync/report.ts +37 -2
  75. package/src/sync/sync-result.ts +5 -2
  76. package/src/sync/sync.ts +13 -5
  77. package/src/task-signals/claude-signals.ts +6 -5
  78. package/src/watch/command.ts +56 -17
  79. package/src/watch/lane-message.ts +1 -1
  80. package/src/watch/lane-progress.ts +51 -2
  81. package/src/watch/lane-report.ts +57 -2
  82. package/src/watch/lane-watch.ts +2 -8
  83. package/src/watch/task-parallel-touched.ts +9 -2
  84. package/src/watch/texts.ts +4 -0
@@ -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({
@@ -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 !== 'assistant' || entry.isSidechain === true) return null
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('"type":"assistant"'))
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, '-')
@@ -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 that `/implement` does not carry already, and the start: `/implement`, or the
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: { lane: options.lane, handover: options.handover, language: settings.planLanguage },
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)]),
@@ -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) and the plan language. */
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
  }
@@ -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 (\`/implement\` Step 4b) and never start a server in your own shell.`,
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: 'Ask before every commit (`/pre-commit`); no push, PR or merge without approval.',
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: 'Start: run `/implement` for your phases, in the order above.',
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 (\`/implement\` Step 4b) und startest nie einen Server in deiner eigenen Shell.`,
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: 'Vor jedem Commit fragst du (`/pre-commit`); kein Push, kein PR und kein Merge ohne Freigabe.',
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: 'Start: Führe `/implement` für deine Phasen aus, in der Reihenfolge oben.',
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
@@ -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'
@@ -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
- * The lines under the first level 2 to 4 heading whose text starts with `headingText` (a suffix such as
61
- * `(PFLICHT ab 3 Phasen)` is allowed), up to the next heading of the same or a higher level.
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 = lines.findIndex((line) => HEADING.exec(line)?.[2].startsWith(headingText))
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
+ }