jackherd 1.14.0 → 1.15.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +15 -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/pull-request.ts +28 -15
  16. package/kit/hooks/hooks/merge-guard/run.ts +6 -3
  17. package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
  18. package/kit/hooks/hooks/model-log/transcript.ts +4 -7
  19. package/kit/hooks/hooks/open-question/record.ts +4 -3
  20. package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
  21. package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
  22. package/kit/hooks/hooks/shared/transcript.ts +7 -4
  23. package/kit/hooks/hooks/task-hint/decision.ts +3 -2
  24. package/kit/hooks/hooks/task-hint/run.ts +2 -4
  25. package/kit/rules/rules/kit/pull-requests.md +1 -0
  26. package/kit/workflow/skills/orchestrate/SKILL.md +22 -9
  27. package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
  28. package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
  29. package/kit/workflow/skills/orchestrate/references/plan-commit.md +5 -5
  30. package/package.json +1 -1
  31. package/schemas/jackherd.schema.json +19 -0
  32. package/src/check/command.ts +1 -1
  33. package/src/check/file-kinds.ts +10 -8
  34. package/src/check/parallel-report.ts +10 -3
  35. package/src/check/settings.ts +4 -0
  36. package/src/check/skill-budget-defaults.ts +17 -5
  37. package/src/check/skill-budgets.ts +49 -19
  38. package/src/claude/paths.ts +12 -0
  39. package/src/cli.ts +1 -0
  40. package/src/config/schema.ts +27 -0
  41. package/src/context/fill.ts +2 -1
  42. package/src/context/log-vocabulary.ts +22 -0
  43. package/src/context/session-log.ts +2 -1
  44. package/src/lane-prompt/build.ts +10 -5
  45. package/src/lane-prompt/command.ts +6 -1
  46. package/src/lane-prompt/input.ts +4 -4
  47. package/src/lane-prompt/texts.ts +18 -10
  48. package/src/plan-contract/command.ts +69 -0
  49. package/src/plan-contract/contract.ts +144 -0
  50. package/src/plan-format/dependencies.ts +1 -4
  51. package/src/plan-format/index.ts +4 -0
  52. package/src/plan-format/lanes.ts +11 -0
  53. package/src/plan-format/markdown.ts +6 -5
  54. package/src/plan-format/model-table.ts +27 -0
  55. package/src/plans/activity-read.ts +98 -0
  56. package/src/plans/ask-coordinator.ts +20 -11
  57. package/src/plans/layout.ts +49 -4
  58. package/src/plans/live.ts +17 -4
  59. package/src/plans/plan-panes.ts +25 -5
  60. package/src/plans/snapshot.ts +18 -8
  61. package/src/plans/subagents-read.ts +4 -3
  62. package/src/plans/subagents.ts +2 -1
  63. package/src/plans/texts.ts +27 -1
  64. package/src/settled/send.ts +80 -4
  65. package/src/shared/parse-json.ts +8 -0
  66. package/src/sync/command.ts +17 -10
  67. package/src/sync/foreign-skills.ts +40 -0
  68. package/src/sync/global-kit.ts +31 -21
  69. package/src/sync/init.ts +2 -2
  70. package/src/sync/kit-exclude.ts +39 -0
  71. package/src/sync/plan-file.ts +1 -1
  72. package/src/sync/render.ts +2 -1
  73. package/src/sync/report.ts +37 -2
  74. package/src/sync/sync-result.ts +5 -2
  75. package/src/sync/sync.ts +13 -5
  76. package/src/task-signals/claude-signals.ts +6 -5
  77. package/src/watch/command.ts +56 -17
  78. package/src/watch/lane-message.ts +1 -1
  79. package/src/watch/lane-progress.ts +51 -2
  80. package/src/watch/lane-report.ts +57 -2
  81. package/src/watch/lane-watch.ts +2 -8
  82. package/src/watch/task-parallel-touched.ts +9 -2
  83. package/src/watch/texts.ts +4 -0
@@ -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
+ }
@@ -0,0 +1,98 @@
1
+ import { CONTENT_BLOCK_TYPE, LOG_ENTRY_TYPE } from '../context/log-vocabulary'
2
+ import { keptLogEntry } from '../context/session-log'
3
+ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
4
+ import { isRecord } from '../shared/is-record'
5
+ import { type RunningSubagent, type SubagentActivity, type SubagentLog, subagentLabelOf } from './subagents'
6
+ import { laneDirectoryOf } from './unlisted-lanes'
7
+
8
+ /** What a running subagent did last, with the name the herd calls it by. */
9
+ export type SubagentLine = { label: string; description: string | null; line: string }
10
+
11
+ /** What a worktree shows of its subagents, and the last action of each. */
12
+ export type LaneSubagents = SubagentActivity & { lines?: readonly SubagentLine[] }
13
+
14
+ /** The end of a log that is looked at: the last action is among the last entries, however long the log is. */
15
+ const SCANNED_ENTRIES = 60
16
+ const LINE_LENGTH = 200
17
+ const PATH_SEGMENTS = 2
18
+ /** The input fields that name what a tool works on, the first one present wins. */
19
+ const TARGET_FIELDS = ['file_path', 'command', 'pattern', 'url', 'query', 'description', 'path']
20
+
21
+ const singleLine = (text: string): string => terminalSafe(text).replace(/\s+/g, ' ').trim()
22
+
23
+ const shortPath = (path: string): string =>
24
+ path.includes('/') ? path.split('/').slice(-PATH_SEGMENTS).join('/') : path
25
+
26
+ const targetOf = (input: unknown): string => {
27
+ if (!isRecord(input)) return ''
28
+ const field = TARGET_FIELDS.find((name) => typeof input[name] === 'string' && input[name] !== '')
29
+ if (field === undefined) return ''
30
+ const value = String(input[field]).split('\n')[0]
31
+ return singleLine(field === 'file_path' || field === 'path' ? shortPath(value) : value)
32
+ }
33
+
34
+ /** The line of one content block, null for a block that is no action: reasoning, whether it carries text or not. */
35
+ const lineOfBlock = (block: unknown): string | null => {
36
+ if (!isRecord(block)) return null
37
+ if (block.type === CONTENT_BLOCK_TYPE.toolUse && typeof block.name === 'string')
38
+ return singleLine(`${block.name} ${targetOf(block.input)}`)
39
+ if (block.type !== CONTENT_BLOCK_TYPE.text || typeof block.text !== 'string') return null
40
+ const first = block.text.split('\n').find((line) => line.trim() !== '')
41
+ return first === undefined ? null : singleLine(first)
42
+ }
43
+
44
+ const lineOfEntry = (line: string): string | null => {
45
+ const entry = keptLogEntry(line)
46
+ if (entry?.type !== LOG_ENTRY_TYPE.assistant || !isRecord(entry.message) || !Array.isArray(entry.message.content))
47
+ return null
48
+ const blocks: unknown[] = entry.message.content
49
+ for (let index = blocks.length - 1; index >= 0; index--) {
50
+ const found = lineOfBlock(blocks[index])
51
+ if (found) return found
52
+ }
53
+ return null
54
+ }
55
+
56
+ const scanned = new WeakMap<readonly string[], string | null>()
57
+
58
+ /**
59
+ * The last action in a log's lines: the newest assistant text line or tool call, a tool as its name and its short
60
+ * target (`Read plans/layout.ts`). Reasoning is left out, as most of it is empty in the log. Cut to a length a line of
61
+ * the herd can take; null when the end of the log holds none. A log that did not change (`followTailLines` hands back
62
+ * the very same array) is not scanned again.
63
+ */
64
+ export const activityLineOf = (lines: readonly string[]): string | null => {
65
+ if (scanned.has(lines)) return scanned.get(lines) ?? null
66
+ let found: string | null = null
67
+ for (let index = lines.length - 1; index >= Math.max(lines.length - SCANNED_ENTRIES, 0) && found === null; index--)
68
+ found = lineOfEntry(lines[index])
69
+ const line = found === null ? null : ellipsize(found, LINE_LENGTH)
70
+ scanned.set(lines, line)
71
+ return line
72
+ }
73
+
74
+ const labelOfLog = ({ id, meta }: SubagentLog): string | null =>
75
+ subagentLabelOf(id, isRecord(meta) && typeof meta.agentType === 'string' ? meta.agentType : null)
76
+
77
+ /**
78
+ * `lanes` with the last action of each running subagent added to the lane it works in; a subagent whose log holds no
79
+ * action, or that works in no known lane, adds none. `lanes` itself stays as it was.
80
+ */
81
+ export const withSubagentLines = (
82
+ lanes: ReadonlyMap<string, SubagentActivity>,
83
+ running: readonly RunningSubagent[],
84
+ logs: readonly SubagentLog[],
85
+ worktreeDirectory: string,
86
+ ): Map<string, LaneSubagents> => {
87
+ const result = new Map<string, LaneSubagents>(lanes)
88
+ const tails = new Map(logs.flatMap((log) => [[labelOfLog(log), log.tail] as const]))
89
+ for (const { label, cwd, description } of running) {
90
+ const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
91
+ const tail = tails.get(label)
92
+ const known = lane === null ? undefined : result.get(lane)
93
+ const line = tail === undefined ? null : activityLineOf(tail)
94
+ if (lane === null || known === undefined || line === null) continue
95
+ result.set(lane, { ...known, lines: [...(known.lines ?? []), { label, description, line }] })
96
+ }
97
+ return result
98
+ }
@@ -1,20 +1,23 @@
1
1
  import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
2
2
  import { resolveCoordinatorName } from '../context/coordinator-name'
3
- import { laneSenderFor } from '../settled/send'
4
- import { type HerdrRunner, parseAgentList } from '../terminal/herdr'
3
+ import { laneSenderForAsync } from '../settled/send'
4
+ import { type HerdrAsyncRunner, parseAgentList } from '../terminal/herdr'
5
5
  import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
6
6
  import { ASK_KIND, type AskTarget, boardRowKey } from './ask-target'
7
7
  import { errorText } from './error-text'
8
8
  import { isQuestionId } from './question-records'
9
9
  import { TEXTS } from './texts'
10
10
 
11
+ /** The footer line while the coordinator is being asked. */
12
+ export const ASKING_NOTICE = 'asking the coordinator …'
13
+
11
14
  /** What `askCoordinator` needs from the outside: the dialog language, the terminal, the repository and herdr. */
12
15
  export type AskDependencies = {
13
16
  language: PlanLanguage
14
17
  adapter: TerminalAdapter
15
18
  /** The repository's directory name, from which the coordinator's herdr name follows. */
16
19
  repository: string
17
- run: HerdrRunner
20
+ run: HerdrAsyncRunner
18
21
  }
19
22
 
20
23
  /** A sentence takes the subject's key and, for an entry of a source, the source it belongs to (else `''`). */
@@ -80,13 +83,18 @@ const subjectOf = (target: AskTarget): string => {
80
83
  return `${SUBJECT_NAMES[target.kind]} ${subjectKeyOf(target)}${scope === '' ? '' : ` of ${scope}`}`
81
84
  }
82
85
 
83
- const agentNamesOf = (run: HerdrRunner): string[] => parseAgentList(run(['agent', 'list'])).map(({ name }) => name)
86
+ const agentNamesOf = async (run: HerdrAsyncRunner): Promise<string[]> =>
87
+ parseAgentList(await run(['agent', 'list'])).map(({ name }) => name)
84
88
 
85
89
  /** The coordinator's herdr name, or why there is none to reach; without a terminal the sender gives the reason. */
86
- const coordinatorOf = ({ adapter, repository, run }: AskDependencies): { name: string } | { reason: string } => {
90
+ const coordinatorOf = async ({
91
+ adapter,
92
+ repository,
93
+ run,
94
+ }: AskDependencies): Promise<{ name: string } | { reason: string }> => {
87
95
  if (adapter === TERMINAL_ADAPTER.none) return { name: resolveCoordinatorName(repository, []) }
88
96
  try {
89
- const names = agentNamesOf(run)
97
+ const names = await agentNamesOf(run)
90
98
  const name = resolveCoordinatorName(repository, names)
91
99
  return names.includes(name) ? { name } : { reason: `no agent named ${name}` }
92
100
  } catch (error) {
@@ -96,14 +104,15 @@ const coordinatorOf = ({ adapter, repository, run }: AskDependencies): { name: s
96
104
 
97
105
  /**
98
106
  * Asks the coordinator, by `herdr agent prompt`, to put the open question or board row `target` to the user. A
99
- * coordinator that is working gets the line too: herdr queues it like a message in the middle of a turn. Returns the
100
- * footer line: who was asked, or why nobody was.
107
+ * coordinator that is working gets the line too: herdr queues it like a message in the middle of a turn. Resolves to
108
+ * the footer line, who was asked or why nobody was; the prompt waits up to `PROMPT_CONFIRM_TIMEOUT_MS`, so a live
109
+ * view shows `ASKING_NOTICE` meanwhile.
101
110
  */
102
- export const askCoordinator = (target: AskTarget, dependencies: AskDependencies): string => {
111
+ export const askCoordinator = async (target: AskTarget, dependencies: AskDependencies): Promise<string> => {
103
112
  if (!isAskable(target)) return TEXTS.notAsked('nothing to ask')
104
- const coordinator = coordinatorOf(dependencies)
113
+ const coordinator = await coordinatorOf(dependencies)
105
114
  if ('reason' in coordinator) return TEXTS.notAsked(coordinator.reason)
106
- const failure = laneSenderFor(dependencies.adapter, dependencies.run)(
115
+ const failure = await laneSenderForAsync(dependencies.adapter, dependencies.run)(
107
116
  coordinator.name,
108
117
  askLine(target, dependencies.language),
109
118
  )