jackherd 1.16.0 → 1.17.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 (155) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +22 -21
  4. package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.js +57 -5
  7. package/dist/plan-format.d.ts +18 -6
  8. package/dist/plan-format.js +8 -1
  9. package/docs/plan-contract.md +1 -0
  10. package/herdr-plugin/herdr-plugin.toml +4 -1
  11. package/herdr-plugin/restore-plans.ts +60 -26
  12. package/herdr-plugin/resume.ts +18 -0
  13. package/install.sh +1 -1
  14. package/kit/hooks/ask-rules.json +11 -0
  15. package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
  16. package/kit/hooks/hooks/approval-gate/index.ts +36 -7
  17. package/kit/hooks/hooks/approval-gate/run.ts +72 -16
  18. package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
  19. package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
  20. package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
  21. package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
  22. package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
  23. package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
  24. package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
  25. package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
  26. package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
  27. package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
  28. package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
  29. package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
  30. package/kit/hooks/hooks/merge-guard/run.ts +110 -28
  31. package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
  32. package/kit/hooks/hooks/plain-question/form.ts +89 -0
  33. package/kit/hooks/hooks/plain-question/index.ts +44 -4
  34. package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
  35. package/kit/hooks/hooks/plain-question/run.ts +14 -7
  36. package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
  37. package/kit/hooks/hooks.json +11 -2
  38. package/kit/review/agents/skill-review.md +3 -2
  39. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  40. package/kit/rules/rules/kit/code-style.md +6 -6
  41. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  42. package/kit/workflow/skills/follow/SKILL.md +2 -3
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +120 -299
  45. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  46. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  47. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  48. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  49. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  50. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  51. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  52. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  53. package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
  54. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  55. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  56. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  57. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  58. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  59. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  60. package/kit/workflow/skills/plan/SKILL.md +3 -2
  61. package/package.json +2 -1
  62. package/recipes/README.md +1 -0
  63. package/recipes/jira-cli.md +1 -1
  64. package/recipes/sources/google-calendar-today.ts +8 -2
  65. package/recipes/sources/jira.ts +10 -2
  66. package/recipes/sources/linear.ts +9 -3
  67. package/recipes/sources/sentry.ts +8 -2
  68. package/recipes/sources.md +4 -2
  69. package/schemas/jackherd.schema.json +67 -0
  70. package/schemas/sources.schema.json +9 -0
  71. package/src/approval/checked.ts +60 -0
  72. package/src/approval/command.ts +16 -0
  73. package/src/check/command.ts +51 -6
  74. package/src/check/lint-pointers.ts +79 -0
  75. package/src/check/lint-shaped.ts +196 -0
  76. package/src/check/plan-structure-check.ts +116 -4
  77. package/src/check/report.ts +9 -3
  78. package/src/check/schema.ts +37 -0
  79. package/src/check/settings.ts +4 -0
  80. package/src/check/skill-budget-defaults.ts +77 -26
  81. package/src/check/skill-budgets.ts +58 -10
  82. package/src/cli.ts +2 -0
  83. package/src/config/personal-sources.ts +46 -10
  84. package/src/config/repository-identity.ts +40 -0
  85. package/src/config/schema.ts +53 -1
  86. package/src/context/command.ts +33 -2
  87. package/src/context/options.ts +33 -8
  88. package/src/context/usage-logs.ts +53 -0
  89. package/src/context/usage.ts +110 -0
  90. package/src/lane/base-command.ts +35 -0
  91. package/src/lane/base.ts +43 -0
  92. package/src/lane/close-check.ts +49 -15
  93. package/src/lane/close-command.ts +71 -11
  94. package/src/lane/close-options.ts +29 -8
  95. package/src/lane/close-survey.ts +13 -1
  96. package/src/lane/close.ts +51 -12
  97. package/src/lane/command.ts +21 -3
  98. package/src/lane/send-command.ts +32 -0
  99. package/src/lane/send-herdr.ts +77 -0
  100. package/src/lane/send-options.ts +23 -0
  101. package/src/lane/send.ts +90 -0
  102. package/src/lane/skill-hint.ts +27 -0
  103. package/src/lane/space-command.ts +30 -0
  104. package/src/lane/space.ts +49 -0
  105. package/src/lane/start-lane-hint.ts +18 -0
  106. package/src/plan-contract/base-check-git.ts +33 -0
  107. package/src/plan-contract/base-check.ts +17 -0
  108. package/src/plan-contract/command.ts +25 -4
  109. package/src/plan-format/plan-structure.ts +17 -5
  110. package/src/plans/ask-coordinator.ts +23 -13
  111. package/src/plans/board-rows.ts +9 -4
  112. package/src/plans/click-hint.ts +4 -0
  113. package/src/plans/command.ts +6 -5
  114. package/src/plans/ensure-panes.ts +14 -3
  115. package/src/plans/git.ts +14 -0
  116. package/src/plans/github.ts +19 -10
  117. package/src/plans/layout.ts +82 -33
  118. package/src/plans/live.ts +8 -12
  119. package/src/plans/plan-panes.ts +21 -4
  120. package/src/plans/selection.ts +29 -0
  121. package/src/plans/settings.ts +4 -0
  122. package/src/resume/command.ts +213 -0
  123. package/src/resume/decision.ts +102 -0
  124. package/src/resume/keeper.ts +65 -0
  125. package/src/resume/location.ts +22 -0
  126. package/src/resume/log.ts +27 -0
  127. package/src/resume/ports.ts +102 -0
  128. package/src/resume/record.ts +116 -0
  129. package/src/resume/targets.ts +84 -0
  130. package/src/resume/texts.ts +27 -0
  131. package/src/secrets/command.ts +15 -5
  132. package/src/sync/herdr-plugin.ts +2 -11
  133. package/src/sync/plan-ask-rules.ts +11 -0
  134. package/src/sync/render.ts +3 -1
  135. package/src/vocabulary/index.ts +13 -0
  136. package/src/waiting/command.ts +13 -4
  137. package/src/watch/command.ts +177 -78
  138. package/src/watch/config-reload.ts +119 -0
  139. package/src/watch/lane-model.ts +11 -1
  140. package/src/watch/lane-progress.ts +70 -8
  141. package/src/watch/lane-report.ts +66 -11
  142. package/src/watch/main-checkout.ts +5 -1
  143. package/src/watch/merge-duties.ts +33 -0
  144. package/src/watch/merge-overlap-read.ts +69 -0
  145. package/src/watch/plan-views.ts +16 -4
  146. package/src/watch/reference-paths.ts +46 -5
  147. package/src/watch/report-questions.ts +5 -2
  148. package/src/watch/single-report.ts +3 -2
  149. package/src/watch/source-dry-run-command.ts +22 -4
  150. package/src/watch/source-reason.ts +58 -0
  151. package/src/watch/source-reload.ts +21 -17
  152. package/src/watch/source-run.ts +24 -7
  153. package/src/watch/sources.ts +9 -2
  154. package/src/watch/texts.ts +14 -3
  155. package/src/watch/waiting-line.ts +4 -3
@@ -0,0 +1,23 @@
1
+ import { CLI_NAME } from '../version/release'
2
+
3
+ /** What `jh lane send` was asked for; `file` is null where `--file` was left out. */
4
+ export type SendOptions = { lane: string; file: string | null }
5
+
6
+ const FILE = '--file'
7
+
8
+ export const LANE_SEND_USAGE = `usage: ${CLI_NAME} lane send <lane> [${FILE} <prompt-file>]
9
+
10
+ Sends the lane's prompt file (default: .claude/handover/<lane>-prompt.md of the main checkout) to its agent and waits
11
+ until it works. On a stall it reads the screen: a lane at work counts as sent, a prompt on the screen gets enter (exit 1 if that does not start the lane), else the
12
+ file is sent once more. A second stall exits 1 with one line for the coordinator.`
13
+
14
+ /** Reads `<lane> [--file <path>]`; throws with the usage on anything else. */
15
+ export const parseSendOptions = (args: readonly string[]): SendOptions => {
16
+ const [lane, ...rest] = args
17
+ if (lane === undefined || lane.startsWith('-')) throw new Error(LANE_SEND_USAGE)
18
+ if (rest.length === 0) return { lane, file: null }
19
+ const [option, file, ...extra] = rest
20
+ if (option !== FILE || file === undefined || file.startsWith('--') || extra.length > 0)
21
+ throw new Error(LANE_SEND_USAGE)
22
+ return { lane, file }
23
+ }
@@ -0,0 +1,90 @@
1
+ /** The codes of herdr's answer to `herdr agent prompt --wait` that do not prove the text is lost. */
2
+ export const SEND_CODE = { stalled: 'agent_prompt_stalled', timeout: 'timeout' } as const
3
+
4
+ /** How long the input line's mark is: whole on any screen width the lane's pane has. */
5
+ const MARK_LENGTH = 40
6
+
7
+ type Awaitable<T> = T | Promise<T>
8
+
9
+ /** What one call of herdr answered: `ok`, else the `error.code` of its answer, null when it named none. */
10
+ export type HerdrAnswer = { ok: boolean; code: string | null }
11
+
12
+ /** The calls the send makes on the lane's agent; the command wires them to `herdr`. */
13
+ export type SendPorts = {
14
+ /** `herdr agent prompt <lane> <text> --wait --until working`. */
15
+ prompt: (lane: string, text: string) => Awaitable<HerdrAnswer>
16
+ /** Whether herdr reports the lane's agent as working. */
17
+ isWorking: (lane: string) => Awaitable<boolean>
18
+ /** The visible screen of the lane's agent. */
19
+ screen: (lane: string) => Awaitable<string>
20
+ /** `herdr agent send-keys <lane> enter`. */
21
+ enter: (lane: string) => Awaitable<HerdrAnswer>
22
+ /** Whether the lane's agent starts working within a few seconds. */
23
+ awaitWorking: (lane: string) => Awaitable<boolean>
24
+ }
25
+
26
+ export type SendOutcome = {
27
+ kind: 'sent' | 'entered' | 'resent' | 'failed'
28
+ exitCode: 0 | 1
29
+ /** The one line for Jack. */
30
+ line: string
31
+ }
32
+
33
+ const isStall = ({ code }: HerdrAnswer): boolean => code === SEND_CODE.stalled || code === SEND_CODE.timeout
34
+
35
+ /** The start of the prompt's first line: what the input line shows while the text waits there, unsent. */
36
+ export const inputLineMarkOf = (prompt: string): string =>
37
+ (prompt.split('\n').find((line) => line.trim() !== '') ?? '').slice(0, MARK_LENGTH)
38
+
39
+ const outcome = (kind: SendOutcome['kind'], lane: string, text: string): SendOutcome => ({
40
+ kind,
41
+ exitCode: kind === 'failed' ? 1 : 0,
42
+ line: `${lane}: ${text}`,
43
+ })
44
+
45
+ const refused = (lane: string, answer: HerdrAnswer): SendOutcome =>
46
+ outcome('failed', lane, `herdr refused the prompt (${answer.code ?? 'unknown'}); ask the user.`)
47
+
48
+ /** Whether the start of the prompt is on the visible screen: in the input line, or already in the transcript. */
49
+ const screenShowsPrompt = async (ports: SendPorts, lane: string, prompt: string): Promise<boolean> => {
50
+ const mark = inputLineMarkOf(prompt)
51
+ return mark !== '' && (await ports.screen(lane)).includes(mark)
52
+ }
53
+
54
+ /**
55
+ * After a stall: done when the lane works; `enter` when the prompt is on the screen (an `enter` on an empty input line
56
+ * does nothing); null when the screen does not show it. `stuck` is a prompt on the screen that `enter` did not start:
57
+ * sending the file again would append it to the text that waits.
58
+ */
59
+ const recovered = async (
60
+ ports: SendPorts,
61
+ lane: string,
62
+ prompt: string,
63
+ ): Promise<'working' | 'entered' | 'stuck' | null> => {
64
+ if (await ports.isWorking(lane)) return 'working'
65
+ if (!(await screenShowsPrompt(ports, lane, prompt))) return null
66
+ const answer = await ports.enter(lane)
67
+ return answer.ok && (await ports.awaitWorking(lane)) ? 'entered' : 'stuck'
68
+ }
69
+
70
+ /**
71
+ * Sends the prompt text to the lane and makes sure it arrived. A stall or a timeout does not prove the text is lost:
72
+ * the screen decides. A lane at work is done; a prompt on the screen gets `enter`, and an `enter` the lane does not
73
+ * answer ends with exit 1, since another send would append to the waiting text; a screen without the prompt sends the
74
+ * text once more. A second stall, or any other error, ends with exit 1 and a line for Jack.
75
+ */
76
+ export const sendLanePrompt = async (ports: SendPorts, lane: string, prompt: string): Promise<SendOutcome> => {
77
+ const first = await ports.prompt(lane, prompt)
78
+ if (first.ok) return outcome('sent', lane, 'prompt sent.')
79
+ if (!isStall(first)) return refused(lane, first)
80
+ const state = await recovered(ports, lane, prompt)
81
+ if (state === 'working') return outcome('sent', lane, 'prompt sent, the lane works.')
82
+ if (state === 'entered') return outcome('entered', lane, 'prompt was waiting in the input line, sent with enter.')
83
+ if (state === 'stuck')
84
+ return outcome('failed', lane, 'the prompt is on the screen and enter did not start the lane; ask the user.')
85
+ const second = await ports.prompt(lane, prompt)
86
+ if (second.ok || (await ports.isWorking(lane))) return outcome('resent', lane, 'prompt sent a second time.')
87
+ return isStall(second)
88
+ ? outcome('failed', lane, 'the prompt stalled twice and the lane does not work; ask the user.')
89
+ : refused(lane, second)
90
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { DEFAULT_SKILL_PREFIX } from '../config/schema'
4
+ import { mainCheckoutOrNull } from '../plans/git'
5
+ import { readSettings } from '../plans/settings'
6
+ import { type Reference, referenceHint, skillCopiesFor, skillDirectoryFor } from '../watch/reference-paths'
7
+
8
+ /** The directory of the orchestrate skill installed for `root`: the project's copy, else the global one; null without. */
9
+ export const installedSkillDirectory = (root: string, home: string = homedir()): string | null =>
10
+ skillDirectoryFor(skillCopiesFor(root, home, readSettings([root]).skillPrefix, DEFAULT_SKILL_PREFIX), existsSync)
11
+
12
+ /**
13
+ * ` · read <skill directory>/<reference>` for the orchestrate skill installed for `root`; empty without one. A command
14
+ * that also runs without orchestrate (`jh check`, `jh plan check`, `jh waiting`) stays silent then, so a user who does
15
+ * not have the skill is not sent to its files; orchestrate's own commands (`jh lane`, the watcher) name the skill
16
+ * instead (`referenceHint` with no directory).
17
+ */
18
+ export const installedReferenceHint = (root: string, reference: Reference, home: string = homedir()): string => {
19
+ const directory = installedSkillDirectory(root, home)
20
+ return directory === null ? '' : referenceHint(reference, directory)
21
+ }
22
+
23
+ /** `installedReferenceHint` for the main checkout of the repository `cwd` is in; empty outside a git checkout. */
24
+ export const installedReferenceHintFor = (cwd: string, reference: Reference): string => {
25
+ const root = mainCheckoutOrNull(cwd)
26
+ return root === null ? '' : installedReferenceHint(root, reference)
27
+ }
@@ -0,0 +1,30 @@
1
+ import { statfsSync } from 'node:fs'
2
+ import { mainCheckout, worktreeRoot } from '../plans/git'
3
+ import { readSettings } from '../plans/settings'
4
+ import { asksForHelp } from '../preview/options'
5
+ import { freeSpaceLine, LANE_SPACE_USAGE, parseSpaceOptions, spaceVerdict } from './space'
6
+
7
+ /** The bytes a normal user can still write on the disk that holds `path`. */
8
+ const freeBytesAt = (path: string): number => {
9
+ const { bavail, bsize } = statfsSync(path)
10
+ return Number(bavail) * Number(bsize)
11
+ }
12
+
13
+ /** `jh lane space [--min-gb <n>]`: prints one line; exit 0 with room for a lane, 1 without, or on a wrong line. */
14
+ export const run = (args: string[], cwd: string = process.cwd()): number => {
15
+ if (asksForHelp(args)) {
16
+ console.log(LANE_SPACE_USAGE)
17
+ return 0
18
+ }
19
+ try {
20
+ const root = mainCheckout(cwd)
21
+ const { minFreeGb } = readSettings([root, worktreeRoot(cwd)])
22
+ const needed = parseSpaceOptions(args).minFreeGb ?? minFreeGb
23
+ const verdict = spaceVerdict(freeBytesAt(root), needed)
24
+ console.log(freeSpaceLine(verdict))
25
+ return verdict.enough ? 0 : 1
26
+ } catch (error) {
27
+ console.error(error instanceof Error ? error.message : String(error))
28
+ return 1
29
+ }
30
+ }
@@ -0,0 +1,49 @@
1
+ import { CLI_NAME } from '../version/release'
2
+
3
+ /**
4
+ * Free space a new lane needs before its dependencies are installed, in gigabytes. Measured 24.10.2026: a first
5
+ * `bun install` of a worktree writes 2.3 GB when the cache holds none of it, and a warm one about 0.1 GB beside the
6
+ * 0.2 GB checkout (the packages are hardlinks into the cache); the rest is room to build and test.
7
+ */
8
+ export const DEFAULT_MIN_FREE_GB = 5
9
+
10
+ const BYTES_PER_GB = 1024 ** 3
11
+ const COLD_INSTALL_GB = 2.3
12
+
13
+ /** `minFreeGb` is null where `--min-gb` was left out: the configured threshold then stands. */
14
+ export type SpaceOptions = { minFreeGb: number | null }
15
+
16
+ export type SpaceVerdict = { enough: boolean; freeGb: number; neededGb: number }
17
+
18
+ const MIN_GB_OPTION = '--min-gb'
19
+
20
+ export const LANE_SPACE_USAGE = `usage: ${CLI_NAME} lane space [${MIN_GB_OPTION} <gigabytes>]
21
+
22
+ Checks that the disk holds enough free space for a new lane before its dependencies are installed; exit 1 and one line
23
+ when it does not. The threshold is lanes.minFreeGb of the config (default ${DEFAULT_MIN_FREE_GB} GB) unless ${MIN_GB_OPTION} says otherwise.`
24
+
25
+ /** Reads `[--min-gb <n>]`; throws with the usage on anything else. */
26
+ export const parseSpaceOptions = (args: readonly string[]): SpaceOptions => {
27
+ if (args.length === 0) return { minFreeGb: null }
28
+ const [option, value, ...rest] = args
29
+ if (option !== MIN_GB_OPTION || rest.length > 0) throw new Error(LANE_SPACE_USAGE)
30
+ const minFreeGb = Number(value)
31
+ if (value === undefined || !Number.isFinite(minFreeGb) || minFreeGb <= 0)
32
+ throw new Error(`${MIN_GB_OPTION} needs a number of gigabytes above 0\n${LANE_SPACE_USAGE}`)
33
+ return { minFreeGb }
34
+ }
35
+
36
+ const roundedDown = (value: number): number => Math.floor(value * 10) / 10
37
+
38
+ /** Whether `freeBytes` reach the threshold; the free value is rounded down to one decimal. */
39
+ export const spaceVerdict = (freeBytes: number, neededGb: number): SpaceVerdict => ({
40
+ enough: freeBytes >= neededGb * BYTES_PER_GB,
41
+ freeGb: roundedDown(freeBytes / BYTES_PER_GB),
42
+ neededGb,
43
+ })
44
+
45
+ /** The one line the command prints, for the coordinator to pass on. */
46
+ export const freeSpaceLine = ({ enough, freeGb, neededGb }: SpaceVerdict): string =>
47
+ enough
48
+ ? `${freeGb} GB free, ${neededGb} GB needed: room for a lane`
49
+ : `no lane started: ${freeGb} GB free, ${neededGb} GB needed (a fresh install takes about ${COLD_INSTALL_GB} GB, a lane needs room to build); free space first`
@@ -0,0 +1,18 @@
1
+ import { homedir } from 'node:os'
2
+ import { mainCheckoutOrNull, worktreeRoot } from '../plans/git'
3
+ import { REFERENCE, referenceHint } from '../watch/reference-paths'
4
+ import { installedSkillDirectory } from './skill-hint'
5
+
6
+ /** `· read <skill directory>/references/start-lane.md`, the line after the output of `jh lane base` and `jh lane prompt`. */
7
+ export const startLaneHintLine = (skillDirectory: string | null): string =>
8
+ referenceHint(REFERENCE.startLane, skillDirectory).trimStart()
9
+
10
+ /**
11
+ * The hint line for a call from the main checkout, where a lane is started from; null from a lane's worktree or outside a
12
+ * checkout, which have no start to guide. The skill copy is the project's, else the global one.
13
+ */
14
+ export const startLaneHintFor = (cwd: string): string | null => {
15
+ const root = mainCheckoutOrNull(cwd)
16
+ if (root === null || worktreeRoot(cwd) !== root) return null
17
+ return startLaneHintLine(installedSkillDirectory(root, homedir()))
18
+ }
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { basename, resolve } from 'node:path'
3
+ import { commitOf, existsOnRef, mainCheckout, worktreeRoot } from '../plans/git'
4
+ import { baseRefOf, boardGlobOf, boardRepoPathOf, readSettings } from '../plans/settings'
5
+ import { baseGapLines, repoPathOf } from './base-check'
6
+
7
+ /** The boards of the main checkout that link the plan, by its file name, as paths from the repository root. */
8
+ const boardPathsLinking = (root: string, plan: string): string[] => {
9
+ const settings = readSettings([root])
10
+ const name = basename(plan)
11
+ return [...new Bun.Glob(boardGlobOf(settings)).scanSync({ cwd: root })]
12
+ .map((file) => resolve(root, file))
13
+ .filter((file) => readFileSync(file, 'utf8').includes(name))
14
+ .map((file) => boardRepoPathOf(root, settings, file))
15
+ }
16
+
17
+ /**
18
+ * The lines that say the plan or a board that links it is not on the base branch (orchestrate § 0), each with its path
19
+ * from the repository root. None where git cannot judge: outside a repository, without the base ref, or for a file
20
+ * outside the repository.
21
+ */
22
+ export const baseGapLinesFor = (file: string, cwd: string): string[] => {
23
+ try {
24
+ const root = mainCheckout(cwd)
25
+ const baseRef = baseRefOf(readSettings([root]))
26
+ if (commitOf(root, baseRef) === null) return []
27
+ const plan = repoPathOf(worktreeRoot(cwd), resolve(cwd, file))
28
+ if (plan === null) return []
29
+ return baseGapLines(baseRef, [plan, ...boardPathsLinking(root, plan)], (path) => existsOnRef(root, baseRef, path))
30
+ } catch {
31
+ return []
32
+ }
33
+ }
@@ -0,0 +1,17 @@
1
+ import { isAbsolute, relative, resolve } from 'node:path'
2
+
3
+ /** The path of `file` from the repository root, without `..`; null for the root itself or a file outside it. */
4
+ export const repoPathOf = (root: string, file: string): string | null => {
5
+ const path = relative(resolve(root), resolve(file))
6
+ return path === '' || path.startsWith('..') || isAbsolute(path) ? null : path
7
+ }
8
+
9
+ /** One line per file of `paths` that `isOnBase` does not find on `baseRef`: git does not resolve `..` in a tree path. */
10
+ export const baseGapLines = (
11
+ baseRef: string,
12
+ paths: readonly string[],
13
+ isOnBase: (path: string) => boolean,
14
+ ): string[] =>
15
+ paths
16
+ .filter((path) => !isOnBase(path))
17
+ .map((path) => `base: ${path} is not on ${baseRef}: ask for its documentation commit (orchestrate § 2 step 0).`)
@@ -3,22 +3,29 @@ import { homedir } from 'node:os'
3
3
  import { resolve } from 'node:path'
4
4
  import { findProjectRoot } from '../config/location'
5
5
  import { readConfig } from '../config/read-config'
6
+ import { installedReferenceHint } from '../lane/skill-hint'
6
7
  import { asksForHelp } from '../preview/options'
7
8
  import { CLI_NAME } from '../version/release'
8
9
  import type { PlanLanguage } from '../vocabulary'
10
+ import { REFERENCE, type Reference, referenceLine } from '../watch/reference-paths'
11
+ import { baseGapLinesFor } from './base-check-git'
9
12
  import { contractOf, type PartFinding, type PlanContract } from './contract'
10
13
 
11
14
  const CHECK_SUBCOMMAND = 'check'
12
15
 
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
16
+ /**
17
+ * Exit codes of `jh plan check`: the contract is met, a part has a gap, the command line or the file is wrong, the
18
+ * contract is met but the plan or a board that links it is not on the base branch.
19
+ */
20
+ export const PLAN_CHECK_EXIT = { met: 0, gaps: 1, error: 2, notOnBase: 3 } as const
15
21
 
16
22
  export const USAGE = `usage: ${CLI_NAME} plan ${CHECK_SUBCOMMAND} <plan.md>
17
23
 
18
24
  Checks one plan against the plan contract: phases, tasks in every phase, a model for every phase, a dependency
19
25
  row for every phase (from two phases on). One line per part, \`file:line\` where a part is incomplete.
26
+ Where git has the base branch, a plan, or a board that links it, that the base branch lacks gets a \`base:\` line with its path.
20
27
  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.`
28
+ Exit 0 met, 1 with gaps, 2 for a wrong command line or an unreadable file, 3 met but not on the base branch.`
22
29
 
23
30
  const where = (file: string, line: number | null): string => (line === null ? '' : ` ${file}:${line}`)
24
31
 
@@ -45,6 +52,12 @@ const readPlan = (file: string): string | null => {
45
52
  }
46
53
  }
47
54
 
55
+ /** The line after the findings that names the reference to read for the gap, for exit 1 and exit 3; none without the skill. */
56
+ const printReference = (reference: Reference, home: string): void => {
57
+ const root = findProjectRoot(process.cwd(), home, existsSync) ?? process.cwd()
58
+ for (const line of referenceLine(installedReferenceHint(root, reference, home))) console.log(line)
59
+ }
60
+
48
61
  const fail = (message: string): number => {
49
62
  console.error(message)
50
63
  return PLAN_CHECK_EXIT.error
@@ -65,5 +78,13 @@ export const run = (args: string[], home: string = homedir()): number => {
65
78
  if ('error' in result) return fail(result.error)
66
79
  const contract = contractOf(markdown, result.language)
67
80
  for (const line of contractLines(file, contract)) console.log(line)
68
- return contract.met ? PLAN_CHECK_EXIT.met : PLAN_CHECK_EXIT.gaps
81
+ if (!contract.met) {
82
+ printReference(REFERENCE.adoptPlan, home)
83
+ return PLAN_CHECK_EXIT.gaps
84
+ }
85
+ const gapLines = baseGapLinesFor(file, process.cwd())
86
+ for (const line of gapLines) console.log(line)
87
+ if (gapLines.length === 0) return PLAN_CHECK_EXIT.met
88
+ printReference(REFERENCE.planCommit, home)
89
+ return PLAN_CHECK_EXIT.notOnBase
69
90
  }
@@ -20,11 +20,11 @@ export type PhaseWithoutGate = PhaseHeading
20
20
  /** A row of the lanes table (1-based line) whose phases cell names no phase id. */
21
21
  export type RowWithoutPhase = OpenLaneRow
22
22
 
23
- /**
24
- * A `### Phase` heading (1-based line) whose tasks are all ticked on the base branch while its own section still
25
- * has open checkboxes not moved on with `→ P<n>` (`openLines`, 1-based).
26
- */
27
- export type MergedPhaseWithOpenBoxes = PhaseWithoutGate & { openLines: number[] }
23
+ /** A `### Phase` heading (1-based line) whose own section has open checkboxes not moved on with `→ P<n>` (`openLines`, 1-based). */
24
+ export type PhaseWithOpenBoxes = PhaseWithoutGate & { openLines: number[] }
25
+
26
+ /** A `PhaseWithOpenBoxes` whose tasks are all ticked on the base branch. */
27
+ export type MergedPhaseWithOpenBoxes = PhaseWithOpenBoxes
28
28
 
29
29
  type PhaseSection = PhaseWithoutGate & { tasks: boolean; gate: boolean; openLines: number[] }
30
30
 
@@ -118,6 +118,18 @@ export const mergedPhasesWithOpenBoxes = (
118
118
  .map(({ line, id, title, openLines }) => ({ line, id, title, openLines }))
119
119
  }
120
120
 
121
+ /**
122
+ * The phase `id` of `markdown` when its own section still has open checkboxes, gate boxes included, that no `→ P<n>`
123
+ * at their end moves on; null when it has none or the plan lacks the phase. Unlike `mergedPhasesWithOpenBoxes` it
124
+ * does not ask whether the phase counts as merged: the caller knows which phase a branch builds.
125
+ */
126
+ export const openBoxesOfPhase = (markdown: string, language: PlanLanguage, id: string): PhaseWithOpenBoxes | null => {
127
+ const section = phaseSections(markdown, language).find((candidate) => candidate.id === id)
128
+ return section && section.openLines.length > 0
129
+ ? { line: section.line, id: section.id, title: section.title, openLines: section.openLines }
130
+ : null
131
+ }
132
+
121
133
  /**
122
134
  * The changed rows of open lanes whose phases cell names no phase id (`P10c`), so neither the view nor the watcher
123
135
  * can tie that step to a phase.
@@ -1,8 +1,10 @@
1
1
  import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
2
2
  import { resolveCoordinatorName } from '../context/coordinator-name'
3
+ import { installedReferenceHintFor } from '../lane/skill-hint'
3
4
  import { laneSenderForAsync } from '../settled/send'
4
5
  import { type HerdrAsyncRunner, parseAgentList } from '../terminal/herdr'
5
6
  import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
7
+ import { REFERENCE } from '../watch/reference-paths'
6
8
  import { ASK_KIND, type AskTarget, boardRowKey } from './ask-target'
7
9
  import { errorText } from './error-text'
8
10
  import { isQuestionId } from './question-records'
@@ -18,29 +20,32 @@ export type AskDependencies = {
18
20
  /** The repository's directory name, from which the coordinator's herdr name follows. */
19
21
  repository: string
20
22
  run: HerdrAsyncRunner
23
+ /** Where the repository is looked up for the skill's reference; the working directory when omitted. */
24
+ cwd?: string
21
25
  }
22
26
 
23
27
  /** A sentence takes the subject's key and, for an entry of a source, the source it belongs to (else `''`). */
24
- type AskSentences = Record<AskTarget['kind'], (subject: string, scope: string) => string>
28
+ type AskSentences = Record<AskTarget['kind'], (subject: string, scope: string, hint: string) => string>
25
29
 
26
30
  const ASK_LINES: Record<PlanLanguage, AskSentences> = {
27
31
  [PLAN_LANGUAGE.de]: {
28
- [ASK_KIND.question]: (id) => `Stell mir die offene Frage ${id} jetzt (jh waiting).`,
29
- [ASK_KIND.boardRow]: (key) => `Stell mir die offene Board-Zeile \`${key}\` jetzt (jh waiting).`,
32
+ [ASK_KIND.question]: (id, _scope, hint) => `Stell mir die offene Frage ${id} jetzt (jh waiting).${hint}`,
33
+ [ASK_KIND.boardRow]: (key, _scope, hint) =>
34
+ `Stell mir die offene Board-Zeile \`${key}\` jetzt (jh waiting).${hint}`,
30
35
  [ASK_KIND.appointmentRun]: (key) => `Führe den Termin \`${key}\` jetzt aus, unter den Freigaben, die er braucht.`,
31
36
  [ASK_KIND.appointmentMove]: (key) =>
32
37
  `Frag den User per Dialog nach der neuen Zeit für den Termin \`${key}\` und trag sie ins Board ein (verschobene Zeile = neuer Termin).`,
33
- [ASK_KIND.sourceEntry]: (title, source) =>
34
- `Sieh dir die Meldung \`${title}\` der Quelle \`${source}\` an und stell mir, falls nötig, die Frage dazu (jh waiting).`,
38
+ [ASK_KIND.sourceEntry]: (title, source, hint) =>
39
+ `Sieh dir die Meldung \`${title}\` der Quelle \`${source}\` an und stell mir, falls nötig, die Frage dazu (jh waiting).${hint}`,
35
40
  },
36
41
  [PLAN_LANGUAGE.en]: {
37
- [ASK_KIND.question]: (id) => `Ask me the open question ${id} now (jh waiting).`,
38
- [ASK_KIND.boardRow]: (key) => `Ask me the open board row \`${key}\` now (jh waiting).`,
42
+ [ASK_KIND.question]: (id, _scope, hint) => `Ask me the open question ${id} now (jh waiting).${hint}`,
43
+ [ASK_KIND.boardRow]: (key, _scope, hint) => `Ask me the open board row \`${key}\` now (jh waiting).${hint}`,
39
44
  [ASK_KIND.appointmentRun]: (key) => `Run the appointment \`${key}\` now, under the approvals it needs.`,
40
45
  [ASK_KIND.appointmentMove]: (key) =>
41
46
  `Ask the user in a dialog for the new time of the appointment \`${key}\` and write it into the board (a moved row is a new appointment).`,
42
- [ASK_KIND.sourceEntry]: (title, source) =>
43
- `Look at the report \`${title}\` of the source \`${source}\` and, if it needs it, put the question about it to me (jh waiting).`,
47
+ [ASK_KIND.sourceEntry]: (title, source, hint) =>
48
+ `Look at the report \`${title}\` of the source \`${source}\` and, if it needs it, put the question about it to me (jh waiting).${hint}`,
44
49
  },
45
50
  }
46
51
 
@@ -60,10 +65,11 @@ const scopeOf = (target: AskTarget): string =>
60
65
  /**
61
66
  * The line sent to the coordinator: a fixed sentence that carries the id of a question, or the sanitised key of a
62
67
  * row (`boardRowKey`), and nothing else from the board or the question, so a prompt line cannot be steered by
63
- * the file it reads. The caller checks the id with `isQuestionId`.
68
+ * the file it reads. The caller checks the id with `isQuestionId`. The sentences that send the coordinator to `jh waiting` end
69
+ * with `hint`, the reference that says how to put the question (none without the orchestrate skill).
64
70
  */
65
- export const askLine = (target: AskTarget, language: PlanLanguage): string =>
66
- ASK_LINES[language][target.kind](subjectKeyOf(target), scopeOf(target))
71
+ export const askLine = (target: AskTarget, language: PlanLanguage, hint = ''): string =>
72
+ ASK_LINES[language][target.kind](subjectKeyOf(target), scopeOf(target), hint)
67
73
 
68
74
  const isAskable = (target: AskTarget): boolean =>
69
75
  target.kind === ASK_KIND.question
@@ -114,7 +120,11 @@ export const askCoordinator = async (target: AskTarget, dependencies: AskDepende
114
120
  if ('reason' in coordinator) return TEXTS.notAsked(coordinator.reason)
115
121
  const failure = await laneSenderForAsync(dependencies.adapter, dependencies.run)(
116
122
  coordinator.name,
117
- askLine(target, dependencies.language),
123
+ askLine(
124
+ target,
125
+ dependencies.language,
126
+ installedReferenceHintFor(dependencies.cwd ?? process.cwd(), REFERENCE.herdAsk),
127
+ ),
118
128
  )
119
129
  return failure === null ? TEXTS.asked(coordinator.name, subjectOf(target)) : TEXTS.notAsked(failure)
120
130
  }
@@ -26,18 +26,23 @@ const newPartOf = (lane: SprintLane, step: PlanStep): PlanStep | null => {
26
26
  return rest.phaseIds.length > 0 ? rest : null
27
27
  }
28
28
 
29
- /** The lane with the checkout's steps it does not have yet; the same object when nothing is new. */
29
+ /** Whether the lane is closed: the base says so, or the checkout marks it cleaned up before the mark is merged. */
30
+ const closedBy = (known: SprintLane, checkout: SprintLane | undefined): boolean =>
31
+ known.closed || checkout?.closed === true
32
+
33
+ /** The lane with the checkout's steps it does not have yet and its closed mark; the same object when nothing is new. */
30
34
  const grownBy = (known: SprintLane, checkout: SprintLane | undefined): SprintLane => {
31
- const grown: SprintLane = { ...known, steps: [...known.steps] }
35
+ const grown: SprintLane = { ...known, steps: [...known.steps], closed: closedBy(known, checkout) }
32
36
  for (const step of checkout?.steps ?? []) {
33
37
  const part = newPartOf(grown, step)
34
38
  if (part !== null) grown.steps.push(part)
35
39
  }
36
- return grown.steps.length > known.steps.length ? grown : known
40
+ return grown.steps.length > known.steps.length || grown.closed !== known.closed ? grown : known
37
41
  }
38
42
 
39
43
  /**
40
- * The board at the base ref with the rows only the checkout has. Base lanes keep their order and their rows;
44
+ * The board at the base ref with the rows only the checkout has. Base lanes keep their order and their rows, and are
45
+ * closed when the checkout marks them cleaned up, so the herd drops a lane before the mark reaches the base;
41
46
  * a lane only the checkout names is appended; a step of a known lane is added when the base does not name its
42
47
  * phases for that plan yet (identity: plan link and phase id), a row without a plan link when its label is new.
43
48
  * Both sides come parsed, this function reads no text.
@@ -3,8 +3,10 @@ import { pullRequestNumberOfKey } from './pull-requests'
3
3
  import { LINKS_KEY_PREFIX } from './row-focus'
4
4
  import {
5
5
  BACKLOG_KEY,
6
+ entryBlockOfKey,
6
7
  finishedKey,
7
8
  isBlockFolded,
9
+ isEntryBlockFolded,
8
10
  isLaneOpen,
9
11
  isLanesOpen,
10
12
  isTaskRestKey,
@@ -55,6 +57,8 @@ const foldingHint = (key: string, state: Selection, agentOf: AgentOf): string |
55
57
  const lane = laneOfKey(key)
56
58
  if (lane !== null) return foldHint(isLaneOpen(state, lane, agentOf(lane)), lane)
57
59
  if (key === LANES_KEY) return foldHint(isLanesOpen(state), TEXTS.lanesHeading)
60
+ const entrySource = entryBlockOfKey(key)
61
+ if (entrySource !== null) return foldHint(!isEntryBlockFolded(state, entrySource), entrySource)
58
62
  const top = topBlockOfKey(key)
59
63
  if (top !== null) return foldHint(!isBlockFolded(state, top), TOP_BLOCK_NAMES[top])
60
64
  const open = state.expanded.has(key)
@@ -4,7 +4,7 @@ import { laneStatusSourceFor } from '../terminal'
4
4
  import { listHerdrAgents } from '../terminal/herdr'
5
5
  import { startedKit } from '../version/started-kit'
6
6
  import { boardFor, foundLanesOf } from './board-pick'
7
- import { ensurePlanPanes } from './ensure-panes'
7
+ import { ensurePlanPaneRun } from './ensure-panes'
8
8
  import { mainCheckout, worktreeRoot } from './git'
9
9
  import { fetchPullRequests } from './github'
10
10
  import { claimOwnTerminal, startExclusive } from './herd-claim'
@@ -12,6 +12,7 @@ import { recallLanes, rememberLanes } from './herd-memory'
12
12
  import { publishedKey } from './herd-state'
13
13
  import { runLive } from './live'
14
14
  import { parseOptions } from './options'
15
+ import { ENSURE_EXIT } from './plan-panes'
15
16
  import { isSupervisedChild } from './restart-decision'
16
17
  import { type PlansSettings, readSettings } from './settings'
17
18
  import { loadSnapshot, type Source } from './snapshot'
@@ -55,12 +56,12 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
55
56
  foundLanesOf(root, settings, () => terminal.listAgents().map((agent) => agent.name)),
56
57
  )
57
58
  if (options.ensure) {
58
- const lines =
59
+ const { lines, exitCode } =
59
60
  settings.adapter === TERMINAL_ADAPTER.herdr
60
- ? ensurePlanPanes(root, settings, picked.file)
61
- : ['the terminal adapter is not herdr: no panes to bring back']
61
+ ? ensurePlanPaneRun(root, settings, picked.file)
62
+ : { lines: ['the terminal adapter is not herdr: no panes to bring back'], exitCode: ENSURE_EXIT.done }
62
63
  for (const line of lines.length > 0 ? lines : ['every herd pane is running']) console.log(line)
63
- return 0
64
+ return exitCode
64
65
  }
65
66
  const source: Source = { root, sprintFile: picked.file, options, settings, terminal, ...agentListOf(settings) }
66
67
  if (options.once || !process.stdout.isTTY) await printOnceWithNotice(source, picked.notice)
@@ -8,6 +8,8 @@ import {
8
8
  agentPanesOf,
9
9
  coordinatorLabelOf,
10
10
  coordinatorPaneOf,
11
+ type EnsureExit,
12
+ ensureExitOf,
11
13
  foregroundOf,
12
14
  missingCoordinatorNote,
13
15
  type PaneAction,
@@ -60,9 +62,14 @@ const labelCoordinator = (label: { pane: string; label: string } | null) => {
60
62
  /**
61
63
  * `herd --ensure`: brings back every missing herd pane beside the coordinator and each lane of the board, and
62
64
  * leaves running ones alone, so it can run after every start. A lane gets its view once its agent runs. One line per
63
- * action or skipped target, none when every view runs. The coordinator's pane is labelled `🐾 Jack` on the way.
65
+ * action or skipped target, none when every view runs, and the exit code (`ENSURE_EXIT`) that says whether the
66
+ * coordinator's pane was found. The coordinator's pane is labelled `🐾 Jack` on the way.
64
67
  */
65
- export const ensurePlanPanes = (root: string, settings: PlansSettings, boardFile: string): string[] => {
68
+ export const ensurePlanPaneRun = (
69
+ root: string,
70
+ settings: PlansSettings,
71
+ boardFile: string,
72
+ ): { lines: string[]; exitCode: EnsureExit } => {
66
73
  const agents = runHerdr(['agent', 'list'])
67
74
  const listed = parseAgentList(agents)
68
75
  const panes = panesWithForeground(agentPanesOf(agents))
@@ -84,5 +91,9 @@ export const ensurePlanPanes = (root: string, settings: PlansSettings, boardFile
84
91
  command: (lane) => viewCommandOf(process.argv, lane),
85
92
  missingCoordinator: missingCoordinatorNote(panes, root, coordinatorName),
86
93
  })
87
- return [...actions.map(apply), ...notes]
94
+ return { lines: [...actions.map(apply), ...notes], exitCode: ensureExitOf(panes, coordinator) }
88
95
  }
96
+
97
+ /** The lines of `ensurePlanPaneRun`, for a caller that brings the panes back without an exit code (the watcher). */
98
+ export const ensurePlanPanes = (root: string, settings: PlansSettings, boardFile: string): string[] =>
99
+ ensurePlanPaneRun(root, settings, boardFile).lines
package/src/plans/git.ts CHANGED
@@ -39,6 +39,11 @@ export const showAtRef = (root: string, ref: string, path: string): string | nul
39
39
  return result.exitCode === 0 ? result.stdout.toString() : null
40
40
  }
41
41
 
42
+ /** Whether `path` (from the repository root, without `..`) exists at `ref`. */
43
+ export const existsOnRef = (root: string, ref: string, path: string): boolean =>
44
+ Bun.spawnSync(['git', '-C', root, 'cat-file', '-e', `${ref}:${path}`], { stdout: 'ignore', stderr: 'ignore' })
45
+ .exitCode === 0
46
+
42
47
  /** The commit `ref` points at, null when it does not exist. */
43
48
  export const commitOf = (root: string, ref: string): string | null =>
44
49
  git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
@@ -151,6 +156,15 @@ export const mainCheckout = (cwd: string): string => {
151
156
  return dirname(commonDirectory)
152
157
  }
153
158
 
159
+ /** `mainCheckout`, null outside a git checkout instead of git's error. */
160
+ export const mainCheckoutOrNull = (cwd: string): string | null => {
161
+ try {
162
+ return mainCheckout(cwd)
163
+ } catch {
164
+ return null
165
+ }
166
+ }
167
+
154
168
  /** The top of the checkout `cwd` is in: the worktree itself, not the main checkout. */
155
169
  export const worktreeRoot = (cwd: string): string => {
156
170
  const top = git(cwd, ['rev-parse', '--show-toplevel'])