jackherd 1.18.2 → 1.20.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 (80) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +30 -25
  4. package/assets/icons/model.svg +1 -0
  5. package/dist/{chunk-N5FAOQZL.js → chunk-JDO6O3UO.js} +52 -2
  6. package/dist/config.d.ts +16 -2
  7. package/dist/config.js +25 -4
  8. package/dist/plan-format.d.ts +84 -1
  9. package/dist/plan-format.js +85 -1
  10. package/docs/plan-contract.md +8 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/hooks/approval-gate/decision.ts +10 -11
  14. package/kit/hooks/hooks/approval-gate/run.ts +23 -3
  15. package/kit/hooks/hooks/browser-guard/decision.ts +7 -0
  16. package/kit/hooks/hooks/browser-guard/index.ts +50 -0
  17. package/kit/hooks/hooks/plain-question/arguments.ts +14 -1
  18. package/kit/hooks/hooks/plain-question/card.ts +102 -0
  19. package/kit/hooks/hooks/plain-question/form.ts +25 -9
  20. package/kit/hooks/hooks/plain-question/index.ts +20 -25
  21. package/kit/hooks/hooks/plain-question/recommendation.ts +6 -6
  22. package/kit/hooks/hooks/return-brief/decision.ts +29 -0
  23. package/kit/hooks/hooks/return-brief/index.ts +37 -0
  24. package/kit/hooks/hooks/return-brief/settings.ts +11 -0
  25. package/kit/hooks/hooks/shared/coordinator-call.ts +25 -0
  26. package/kit/hooks/hooks/shared/transcript.ts +9 -0
  27. package/kit/hooks/hooks.json +18 -1
  28. package/kit/review/agents/security-audit.md +1 -1
  29. package/kit/workflow/agents/browser-check.md +29 -0
  30. package/kit/workflow/skills/implement/SKILL.md +5 -3
  31. package/kit/workflow/skills/orchestrate/SKILL.md +36 -14
  32. package/kit/workflow/skills/orchestrate/references/acceptance.md +10 -9
  33. package/kit/workflow/skills/orchestrate/references/coordinator-clear.md +45 -0
  34. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +15 -12
  35. package/kit/workflow/skills/orchestrate/references/start-lane.md +3 -3
  36. package/kit/workflow/skills/plan/SKILL.md +2 -0
  37. package/kit/workflow/skills/plan/references/template-English.md +8 -0
  38. package/kit/workflow/skills/plan/references/template-German.md +8 -0
  39. package/package.json +1 -1
  40. package/schemas/jackherd.schema.json +21 -0
  41. package/src/brief/command.ts +45 -0
  42. package/src/brief/lane-brief.ts +51 -0
  43. package/src/brief/lane-facts.ts +64 -0
  44. package/src/check/skill-budget-defaults.ts +13 -2
  45. package/src/cli.ts +1 -0
  46. package/src/config/schema.ts +24 -0
  47. package/src/lane/command.ts +3 -1
  48. package/src/lane/port-command.ts +37 -0
  49. package/src/lane/port.ts +56 -0
  50. package/src/lane-prompt/texts.ts +2 -2
  51. package/src/plan-contract/command.ts +1 -0
  52. package/src/plan-contract/contract.ts +28 -5
  53. package/src/plan-format/index.ts +1 -0
  54. package/src/plan-format/plan-head.ts +131 -0
  55. package/src/plans/active-tasks.ts +8 -1
  56. package/src/plans/click-hint.ts +2 -1
  57. package/src/plans/focus-overlay.ts +5 -2
  58. package/src/plans/folded-detail.ts +47 -0
  59. package/src/plans/lane-line.ts +86 -0
  60. package/src/plans/lane-reason.ts +24 -4
  61. package/src/plans/layout-phase.ts +4 -20
  62. package/src/plans/layout-summary.ts +56 -45
  63. package/src/plans/layout.ts +178 -44
  64. package/src/plans/port-listening.ts +25 -0
  65. package/src/plans/selection.ts +11 -0
  66. package/src/plans/settings.ts +4 -0
  67. package/src/plans/snapshot.ts +27 -9
  68. package/src/plans/task-checklist.ts +57 -0
  69. package/src/plans/texts.ts +34 -9
  70. package/src/resume/command.ts +40 -6
  71. package/src/resume/coordinator-read.ts +136 -0
  72. package/src/resume/state.ts +163 -0
  73. package/src/resume/texts.ts +6 -0
  74. package/src/sync/render.ts +5 -1
  75. package/src/vocabulary/index.ts +74 -0
  76. package/src/watch/clear-moment.ts +228 -0
  77. package/src/watch/command.ts +92 -8
  78. package/src/watch/lane-report.ts +5 -1
  79. package/src/watch/reference-paths.ts +8 -4
  80. package/src/watch/watch-events.ts +13 -0
@@ -0,0 +1,45 @@
1
+ import { boardFor, foundLanesOf } from '../plans/board-pick'
2
+ import { mainCheckout, worktreeRoot } from '../plans/git'
3
+ import { readSettings } from '../plans/settings'
4
+ import { CLI_NAME } from '../version/release'
5
+ import { briefLines } from './lane-brief'
6
+ import { laneFactsOf } from './lane-facts'
7
+
8
+ const USAGE = `usage: ${CLI_NAME} brief [--board <board.md>]`
9
+
10
+ const BOARD_FLAG = '--board'
11
+
12
+ /** The board a command line names (`--board <path>`), null for none; undefined for a command line that is wrong. */
13
+ const boardOf = (args: readonly string[]): string | null | undefined => {
14
+ if (args.length === 0) return null
15
+ return args.length === 2 && args[0] === BOARD_FLAG ? args[1] : undefined
16
+ }
17
+
18
+ /**
19
+ * `jh brief` prints three lines for each open lane of the board: its goal, its status and what it needs from the
20
+ * user, from the head of its plan and from the user's open questions and sent files. Exit 0, 1 on a wrong command line
21
+ * or a board that cannot be read.
22
+ */
23
+ export const run = async (args: string[]): Promise<number> => {
24
+ if (args.includes('--help') || args.includes('-h')) {
25
+ console.log(USAGE)
26
+ return 0
27
+ }
28
+ const boardPath = boardOf(args)
29
+ if (boardPath === undefined) {
30
+ console.error(USAGE)
31
+ return 1
32
+ }
33
+ try {
34
+ const root = mainCheckout(process.cwd())
35
+ const here = worktreeRoot(process.cwd())
36
+ const settings = readSettings([here, root])
37
+ const picked = boardFor(boardPath, [root, here], settings, () => foundLanesOf(root, settings, () => []))
38
+ for (const line of briefLines(laneFactsOf(root, settings, picked.file, Date.now()), settings.planLanguage))
39
+ console.log(line)
40
+ return 0
41
+ } catch (error) {
42
+ console.error(error instanceof Error ? error.message : String(error))
43
+ return 1
44
+ }
45
+ }
@@ -0,0 +1,51 @@
1
+ import { HEAD_LINE, type PlanLanguage, parsePlanHead } from '../plan-format'
2
+ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
3
+
4
+ /** What the brief needs of one lane: its name, the text of its plan (null without one) and what waits on the user in it. */
5
+ export type LaneFacts = { name: string; plan: string | null; openQuestions: number; sentFiles: number }
6
+
7
+ /** The most of a head line the brief shows; a longer one is cut with an ellipsis. */
8
+ const LINE_WIDTH = 110
9
+
10
+ const NO_OPEN_LANES = 'no open lanes'
11
+ const HEAD_MISSING = 'head missing'
12
+ const NO_PLAN_LINKED = 'no plan linked'
13
+ const UNKNOWN = 'unknown'
14
+
15
+ const counted = (count: number, noun: string): string => `${count} ${noun}${count === 1 ? '' : 's'}`
16
+
17
+ const shown = (text: string): string => ellipsize(terminalSafe(text), LINE_WIDTH)
18
+
19
+ /** What a lane needs from the user: its open decisions in the head, the open questions and the sent files, in numbers. */
20
+ const needsOf = (decisions: string | undefined, { openQuestions, sentFiles }: LaneFacts): string => {
21
+ const parts = [
22
+ ...(decisions ? [shown(decisions)] : []),
23
+ ...(openQuestions > 0 ? [counted(openQuestions, 'open question')] : []),
24
+ ...(sentFiles > 0 ? [counted(sentFiles, 'sent file')] : []),
25
+ ]
26
+ return parts.length > 0 ? parts.join(' · ') : 'nothing'
27
+ }
28
+
29
+ /**
30
+ * The three lines of one lane: its goal (what changes for the user), its status and what it needs from the user. A
31
+ * lane whose plan has no head, or no plan at all, says so instead of guessing; the counts still show.
32
+ */
33
+ export const laneBrief = (facts: LaneFacts, language: PlanLanguage): string[] => {
34
+ const head = facts.plan === null ? null : parsePlanHead(facts.plan, language)
35
+ const changes = head?.texts[HEAD_LINE.changes]
36
+ if (!head || !changes)
37
+ return [
38
+ `${facts.name} · ${facts.plan === null ? NO_PLAN_LINKED : HEAD_MISSING}`,
39
+ ` Status: ${UNKNOWN}`,
40
+ ` Needs you: ${needsOf(undefined, facts)}`,
41
+ ]
42
+ return [
43
+ `${facts.name} · Goal: ${shown(changes)}`,
44
+ ` Status: ${shown(head.texts[HEAD_LINE.status] ?? UNKNOWN)}`,
45
+ ` Needs you: ${needsOf(head.texts[HEAD_LINE.decisions], facts)}`,
46
+ ]
47
+ }
48
+
49
+ /** The brief of all lanes, three lines each; a note when none is open. */
50
+ export const briefLines = (lanes: readonly LaneFacts[], language: PlanLanguage): string[] =>
51
+ lanes.length === 0 ? [NO_OPEN_LANES] : lanes.flatMap((lane) => laneBrief(lane, language))
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, join, relative } from 'node:path'
3
+ import { parseSprintLanes, resolvePlanPath, type SprintLane } from '../plan-format'
4
+ import { type QuestionRecord } from '../plans/question-records'
5
+ import { type SentFileRecord, sentLaneOf } from '../plans/sent-files'
6
+ import type { PlansSettings } from '../plans/settings'
7
+ import { laneDirectoryOf } from '../plans/unlisted-lanes'
8
+ import { readWaitingRecords } from '../plans/waiting-records'
9
+ import { isStillWaiting } from '../plans/waiting-window'
10
+ import type { LaneFacts } from './lane-brief'
11
+
12
+ /** The plan a lane works on: the link of its newest step that has one. */
13
+ const planHrefOf = ({ steps }: SprintLane): string | null =>
14
+ steps.filter((step) => step.planHref !== null).at(-1)?.planHref ?? null
15
+
16
+ /** The plan file as the lane's worktree has it (it ticks and writes the head there), else the main checkout's copy. */
17
+ const planTextOf = (root: string, worktrees: string, lane: string, planPath: string): string | null => {
18
+ const candidates = [join(worktrees, lane, planPath), join(root, planPath)]
19
+ const found = candidates.find((path) => existsSync(path))
20
+ return found === undefined ? null : readFileSync(found, 'utf8')
21
+ }
22
+
23
+ const openQuestionsOf = (
24
+ records: readonly QuestionRecord[],
25
+ lane: string,
26
+ worktreeDirectory: string,
27
+ now: number,
28
+ ): number =>
29
+ records.filter(
30
+ ({ closedAt, askedAt, cwd }) =>
31
+ isStillWaiting(closedAt, askedAt, now) && laneDirectoryOf(cwd, worktreeDirectory) === lane,
32
+ ).length
33
+
34
+ const openSendsOf = (
35
+ records: readonly SentFileRecord[],
36
+ lane: string,
37
+ worktreeDirectory: string,
38
+ now: number,
39
+ ): number =>
40
+ records.filter(
41
+ (record) => isStillWaiting(record.closedAt, record.sentAt, now) && sentLaneOf(record, worktreeDirectory) === lane,
42
+ ).length
43
+
44
+ /**
45
+ * The facts of each open lane of the board `boardFile` names, read from the checkout `root` (the plan from the lane's
46
+ * worktree first) and from the open questions and sent files of the user's records.
47
+ */
48
+ export const laneFactsOf = (root: string, settings: PlansSettings, boardFile: string, now: number): LaneFacts[] => {
49
+ const sprintDirectory = dirname(relative(root, boardFile))
50
+ const worktreeDirectory = join(root, settings.worktreeDirectory)
51
+ const records = readWaitingRecords(root)
52
+ return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.planLanguage)
53
+ .filter(({ closed }) => !closed)
54
+ .map((lane) => {
55
+ const href = planHrefOf(lane)
56
+ return {
57
+ name: lane.name,
58
+ plan:
59
+ href === null ? null : planTextOf(root, worktreeDirectory, lane.name, resolvePlanPath(sprintDirectory, href)),
60
+ openQuestions: openQuestionsOf(records.questions, lane.name, worktreeDirectory, now),
61
+ sentFiles: openSendsOf(records.sent, lane.name, worktreeDirectory, now),
62
+ }
63
+ })
64
+ }
@@ -31,13 +31,18 @@ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
31
31
  // plan; the proof became a word diff, since a short name goes into an existing line (+74); skill-review: the
32
32
  // done check names `jh check`, the gate and short-name rules as the code reads them (+24).
33
33
  '.claude/skills/orchestrate/references/adopt-plan.md': 680,
34
- '.claude/skills/orchestrate/references/acceptance.md': 200,
34
+ // Browser helper: the look goes to `browser-check`, and a session without `SendUserFile` names the paths (+50).
35
+ '.claude/skills/orchestrate/references/acceptance.md': 250,
35
36
  // Core cut: the `due` row of the wake-up table moved here (+33).
36
37
  '.claude/skills/orchestrate/references/appointments.md': 290,
37
38
  // Resume: `--branch` finishes a lane whose worktree is gone, a detached worktree on the base closes, a closed lane
38
39
  // is reported (+41).
39
40
  '.claude/skills/orchestrate/references/close-lane.md': 307,
40
- '.claude/skills/orchestrate/references/coordinator-handover.md': 567,
41
+ // Clearing is the normal way: the hand-over names it and what the note leaves to `jh resume --coordinator`, and what
42
+ // a refused approval means (+63).
43
+ '.claude/skills/orchestrate/references/coordinator-handover.md': 630,
44
+ // New: runs on every clear and replaces the longer hand-over there.
45
+ '.claude/skills/orchestrate/references/coordinator-clear.md': 580,
41
46
  '.claude/skills/orchestrate/references/draft-review.md': 300,
42
47
  // P33: a source entry the user must read or decide becomes a row of what waits on them, and when it closes.
43
48
  // Core cut: the `deploy`, `stalled` and lane `warn` rows of the wake-up table moved here (+101), and the checks
@@ -79,6 +84,7 @@ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
79
84
  '.claude/skills/start/SKILL.md': 630,
80
85
  '.claude/skills/uncle-bob/SKILL.md': 466,
81
86
  '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 5100,
87
+ '.claude/agents/browser-check.md': 380,
82
88
  '.claude/agents/devops.md': 104,
83
89
  '.claude/agents/explore.md': 132,
84
90
  '.claude/agents/implement.md': 303,
@@ -102,6 +108,11 @@ export const KIT_SKILL_CEILINGS: Readonly<Record<string, number>> = {
102
108
  // Orchestrate core plan P1: the reviewer also judges the whole length of a file against its target (+12).
103
109
  // Lint before prose: lens 7 for lint-shaped prose and the sentence on the `Lint pointers` result (+33).
104
110
  '.claude/agents/skill-review.md': 513,
111
+ // Plan head: every plan opens with a `For you` head of five short lines a person reads first, which
112
+ // `jh plan check` reports; cut to its labels; skill-review: the status line says what `N` counts. Measured size (+42).
113
+ '.claude/skills/plan/references/template-English.md': 578,
114
+ // Plan head: the same `Für dich` head as the English template, with what `N` counts; measured size (+41).
115
+ '.claude/skills/plan/references/template-German.md': 542,
105
116
  // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
106
117
  // Plain words: no plan code without its words in a short question.
107
118
  // Any language: a pointer in § 0 and a section in events.md for `plan missing`.
package/src/cli.ts CHANGED
@@ -19,6 +19,7 @@ const ALIASES = new Set<string>([HERD_COMMAND.alias])
19
19
  /** One line per command; each entry lazily imports the module that exports its `run`. */
20
20
  export const COMMANDS: CommandTable = {
21
21
  approval: () => import('./approval/command'),
22
+ brief: () => import('./brief/command'),
22
23
  check: () => import('./check/command'),
23
24
  config: () => import('./config/command'),
24
25
  context: () => import('./context/command'),
@@ -357,14 +357,34 @@ const repositorySources = z
357
357
  })
358
358
  })
359
359
 
360
+ const DEFAULT_CLEAR_TOKENS = 250_000
361
+
360
362
  const coordinatorSchema = z
361
363
  .strictObject({
362
364
  /** Tokens in the coordinator's context from which the watcher reports `coordinator warn`. */
363
365
  warnTokens: tokenCount.default(350_000),
364
366
  /** Tokens in the coordinator's context from which the watcher reports `coordinator alert`. */
365
367
  alertTokens: tokenCount.default(500_000),
368
+ /**
369
+ * Tokens in the coordinator's context from which the watcher reports `coordinator clear` at a quiet moment; below
370
+ * `warnTokens`. Unset, 250 000, or just below `warnTokens` where a project set those lower.
371
+ */
372
+ clearTokens: tokenCount.optional(),
373
+ /**
374
+ * Tokens in the coordinator's context from which a merge with the board written afterwards is a moment to clear too.
375
+ * At or above `clearTokens`, a merge adds no moment of its own.
376
+ */
377
+ clearAfterMergeTokens: tokenCount.default(150_000),
366
378
  })
367
379
  .refine(warnBelowAlert, WARN_BELOW_ALERT)
380
+ .refine(({ clearTokens, warnTokens }) => clearTokens === undefined || clearTokens < warnTokens, {
381
+ message: 'clearTokens must be below warnTokens',
382
+ path: ['clearTokens'],
383
+ })
384
+ .transform(({ clearTokens, ...thresholds }) => ({
385
+ ...thresholds,
386
+ clearTokens: clearTokens ?? Math.min(DEFAULT_CLEAR_TOKENS, thresholds.warnTokens - 1),
387
+ }))
368
388
 
369
389
  /**
370
390
  * An SQL tool of an MCP server by its full name (`mcp__<server>__execute_sql`), never one whose name says `prod`. The
@@ -583,6 +603,8 @@ export const jackherdConfigSchema = z.strictObject({
583
603
  lockFile: lockFilePath.optional(),
584
604
  /** Free gigabytes the disk must hold before `jh lane space` lets a new lane install its dependencies; 5 when left out. */
585
605
  minFreeGb: z.number().positive().optional(),
606
+ /** The port `jh lane port` starts counting from when it picks a lane's dev-server port; 3100 when left out. */
607
+ basePort: z.number().int().min(1).max(65535).optional(),
586
608
  })
587
609
  .prefault({}),
588
610
  models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
@@ -602,6 +624,8 @@ export const jackherdConfigSchema = z.strictObject({
602
624
  fetchIntervalMinutes: z.number().min(0).default(5),
603
625
  /** The running task of a folded lane shimmers in `jh herd`; `false` keeps it still. `NO_COLOR` keeps it still too. */
604
626
  shimmer: z.boolean().default(true),
627
+ /** Minutes without a prompt after which the `return-brief` hook asks the coordinator to show `jh brief` first; 0 turns it off. */
628
+ briefAfterMinutes: z.number().min(0).default(60),
605
629
  /** Links in a plan that the sprint page shows as chips; the list replaces the built-in kinds. */
606
630
  linkKinds: z
607
631
  .array(
@@ -4,6 +4,7 @@ import { startLaneHintFor } from './start-lane-hint'
4
4
 
5
5
  const CLOSE = 'close'
6
6
  const SPACE = 'space'
7
+ const PORT = 'port'
7
8
  const SEND = 'send'
8
9
  const BASE = 'base'
9
10
 
@@ -15,12 +16,13 @@ const withStartLaneHint = (code: number, args: readonly string[], cwd: string):
15
16
  }
16
17
 
17
18
  /**
18
- * `jh lane close …` closes a lane, `jh lane space …` checks the disk for a new one, `jh lane send …` sends its prompt
19
+ * `jh lane close …` closes a lane, `jh lane space …` checks the disk for a new one, `jh lane port …` names its dev-server port, `jh lane send …` sends its prompt
19
20
  * file, `jh lane base …` names the ref its worktree starts from, everything else is `jh lane prompt …`. `close` must
20
21
  * come first; `jh lane --board b.md close x` reaches the prompt parser and is refused there.
21
22
  */
22
23
  export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
23
24
  if (args[0] === SPACE) return (await import('./space-command')).run(args.slice(1), cwd)
25
+ if (args[0] === PORT) return (await import('./port-command')).run(args.slice(1), cwd)
24
26
  if (args[0] === SEND) return (await import('./send-command')).run(args.slice(1), cwd)
25
27
  if (args[0] === BASE)
26
28
  return withStartLaneHint(await (await import('./base-command')).run(args.slice(1), cwd), args.slice(1), cwd)
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { parseSprintLanes } from '../plan-format'
4
+ import { terminalSafe } from '../plan-format/terminal-text'
5
+ import { boardFor } from '../plans/board-pick'
6
+ import { mainCheckout, worktreeRoot } from '../plans/git'
7
+ import { isPortListening } from '../plans/port-listening'
8
+ import { readSettings } from '../plans/settings'
9
+ import { asksForHelp } from '../preview/options'
10
+ import { freePortNotListening, LANE_PORT_USAGE, MAX_PORT, parsePortOptions, portsOfRunningLanes } from './port'
11
+
12
+ /**
13
+ * `jh lane port [--board <board.md>]`: prints the dev-server port for a new lane, the lowest from `lanes.basePort` that
14
+ * nothing listens on and no running lane of the board holds. Exit 0 printed, 1 on a wrong line, an unreadable board or
15
+ * no free port.
16
+ */
17
+ export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
18
+ if (asksForHelp(args)) {
19
+ console.log(LANE_PORT_USAGE)
20
+ return 0
21
+ }
22
+ try {
23
+ const { boardPath } = parsePortOptions(args)
24
+ const root = mainCheckout(cwd)
25
+ const settings = readSettings([root, worktreeRoot(cwd)])
26
+ const board = boardFor(boardPath === null ? null : resolve(cwd, boardPath), [root], settings, () => [])
27
+ if (board.notice) console.error(terminalSafe(board.notice))
28
+ const reserved = portsOfRunningLanes(parseSprintLanes(readFileSync(board.file, 'utf8'), settings.planLanguage))
29
+ const port = await freePortNotListening(settings.basePort, reserved, MAX_PORT, isPortListening)
30
+ if (port === null) throw new Error(`no free port from ${settings.basePort} to ${MAX_PORT}`)
31
+ console.log(port)
32
+ return 0
33
+ } catch (error) {
34
+ console.error(terminalSafe(error instanceof Error ? error.message : String(error)))
35
+ return 1
36
+ }
37
+ }
@@ -0,0 +1,56 @@
1
+ import type { SprintLane } from '../plan-format'
2
+ import { CLI_NAME } from '../version/release'
3
+
4
+ /** The port the first lane's dev server gets when the config names none (`lanes.basePort`). */
5
+ export const DEFAULT_BASE_PORT = 3100
6
+
7
+ /** The highest port a machine has. */
8
+ export const MAX_PORT = 65535
9
+
10
+ /** `boardPath` is null where `--board` was left out: the board is then picked as for `jh herd`. */
11
+ export type PortOptions = { boardPath: string | null }
12
+
13
+ const BOARD = '--board'
14
+
15
+ export const LANE_PORT_USAGE = `usage: ${CLI_NAME} lane port [${BOARD} <board.md>]
16
+
17
+ Prints the dev-server port for a new lane: the lowest port from lanes.basePort of the config (default ${DEFAULT_BASE_PORT})
18
+ that nothing listens on and no lane still running holds in the port column of the board. Rows of cleaned-up lanes
19
+ reserve nothing. Exit 1 and one line when no port up to ${MAX_PORT} is free.`
20
+
21
+ /** The lowest port from `base` up to `max` (both included) that is not in `taken`; null when there is none. */
22
+ export const firstFreePort = (base: number, taken: ReadonlySet<number>, max: number): number | null => {
23
+ for (let port = base; port <= max; port += 1) if (!taken.has(port)) return port
24
+ return null
25
+ }
26
+
27
+ /**
28
+ * The lowest port from `base` that is neither in `reserved` nor accepts a connection according to `isListening`; the
29
+ * probe is asked only for ports the board does not already hold. Null when none is free up to `max`.
30
+ */
31
+ export const freePortNotListening = async (
32
+ base: number,
33
+ reserved: ReadonlySet<number>,
34
+ max: number,
35
+ isListening: (port: number) => Promise<boolean>,
36
+ ): Promise<number | null> => {
37
+ const taken = new Set(reserved)
38
+ for (let port = firstFreePort(base, taken, max); port !== null; port = firstFreePort(port, taken, max)) {
39
+ if (!(await isListening(port))) return port
40
+ taken.add(port)
41
+ }
42
+ return null
43
+ }
44
+
45
+ /** The ports the board's lanes that are not cleaned up hold; a lane without a port in its row reserves none. */
46
+ export const portsOfRunningLanes = (lanes: readonly SprintLane[]): Set<number> =>
47
+ new Set(lanes.flatMap(({ port, closed }) => (closed || port === null ? [] : [port])))
48
+
49
+ /** Reads `[--board <file>]`; throws with the usage on anything else. */
50
+ export const parsePortOptions = (args: readonly string[]): PortOptions => {
51
+ if (args.length === 0) return { boardPath: null }
52
+ const [option, value, ...rest] = args
53
+ if (option !== BOARD || value === undefined || value.startsWith('--') || rest.length > 0)
54
+ throw new Error(LANE_PORT_USAGE)
55
+ return { boardPath: value }
56
+ }
@@ -52,7 +52,7 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
52
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
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.`,
54
54
  report: (reportPath) =>
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.`,
55
+ `The report at the end of each step goes to \`${reportPath}\` in the worktree, in the short form of \`/implement\` Step 6: five lines, each opening with its field, then the 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.`,
56
56
  textBlock:
57
57
  'Every question to the coordinator is a text block at the end of your turn, never a dialog (`AskUserQuestion`), also where a skill or a project rule asks for one, the commit confirmation of `/pre-commit` included: the question with the situation, the problem and what each option leads to, in everyday words (the user may not know your terms; a plan code like `P7` appears only in brackets after the words), the options, your recommendation, the evidence at `file:line`.',
58
58
  secret:
@@ -89,7 +89,7 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
89
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
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.`,
91
91
  report: (reportPath) =>
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.`,
92
+ `Den Bericht am Ende jedes Schritts schreibst du nach \`${reportPath}\` im Worktree, in der Kurzform von \`/implement\` Step 6: fünf Zeilen, jede mit ihrem Feld am Anfang, dann die 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.`,
93
93
  textBlock:
94
94
  'Jede Frage an den Koordinator ist ein Textblock am Ende deines Turns, nie ein Dialog (`AskUserQuestion`), auch wo ein Skill oder eine Projektregel einen verlangt, die Commit-Bestätigung von `/pre-commit` eingeschlossen: die Frage mit der Lage, dem Problem und wohin jede Option führt, in Alltagsworten (der User kennt deine Begriffe vielleicht nicht; ein Plan-Kürzel wie `P7` nur in Klammern hinter den Worten), die Optionen, deine Empfehlung, der Beleg als `datei:zeile`.',
95
95
  secret:
@@ -23,6 +23,7 @@ export const USAGE = `usage: ${CLI_NAME} plan ${CHECK_SUBCOMMAND} <plan.md>
23
23
 
24
24
  Checks one plan against the plan contract: phases, tasks in every phase, a model for every phase, a dependency
25
25
  row for every phase (from two phases on). One line per part, \`file:line\` where a part is incomplete.
26
+ A fifth line, \`head:\` present, missing or stale, reports the plan's "For you" head; it is a note and never changes the exit code.
26
27
  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.
27
28
  The plan language comes from .jackherd/config.json.
28
29
  Exit 0 met, 1 with gaps, 2 for a wrong command line or an unreadable file, 3 met but not on the base branch.`
@@ -1,4 +1,6 @@
1
1
  import {
2
+ checkPlanHead,
3
+ type HeadStatus,
2
4
  type ModelRow,
3
5
  NO_DEPENDENCY_TABLE,
4
6
  type PhaseHeading,
@@ -11,25 +13,31 @@ import {
11
13
  } from '../plan-format'
12
14
  import type { PlanLanguage } from '../vocabulary'
13
15
 
14
- /** The four parts of the plan contract. */
16
+ /** The five parts of the plan contract; the head is a note, never a gap. */
15
17
  export const CONTRACT_PART = {
16
18
  phases: 'phases',
17
19
  tasks: 'tasks',
18
20
  model: 'model',
19
21
  dependencies: 'dependencies',
22
+ head: 'head',
20
23
  } as const
21
24
 
22
25
  export type ContractPart = (typeof CONTRACT_PART)[keyof typeof CONTRACT_PART]
23
26
 
24
27
  /** 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
28
+ export const PART_STATUS = {
29
+ present: 'present',
30
+ missing: 'missing',
31
+ malformed: 'malformed',
32
+ stale: 'stale',
33
+ } as const
26
34
 
27
35
  export type PartStatus = (typeof PART_STATUS)[keyof typeof PART_STATUS]
28
36
 
29
37
  /** `line` is the 1-based line of the first problem, or of the part's first sign; null when the plan shows none. */
30
38
  export type PartFinding = { part: ContractPart; status: PartStatus; line: number | null; message: string }
31
39
 
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. */
40
+ /** `met` is false when a part is malformed, or phases, tasks or the model table are missing; a missing dependency table or a missing or stale head is a note. */
33
41
  export type PlanContract = { parts: PartFinding[]; met: boolean }
34
42
 
35
43
  /** Fewer phases than this need no dependency table: there is nothing to put side by side. */
@@ -123,9 +131,23 @@ const dependenciesPart = (markdown: string, language: PlanLanguage, headings: re
123
131
  )
124
132
  }
125
133
 
126
- /** A missing dependency table is a note, not a gap: the phases then run one after another. */
134
+ /** Notes, not gaps: a missing dependency table (the phases then run one after another) and the head, which old plans lack. */
135
+ const NOTE_PARTS: readonly ContractPart[] = [CONTRACT_PART.dependencies, CONTRACT_PART.head]
136
+
127
137
  export const isGap = ({ part, status }: PartFinding): boolean =>
128
- status === PART_STATUS.malformed || (status === PART_STATUS.missing && part !== CONTRACT_PART.dependencies)
138
+ status === PART_STATUS.malformed || (status === PART_STATUS.missing && !NOTE_PARTS.includes(part))
139
+
140
+ /** The head's verdict as a part status; `satisfies` fails the type check when the two vocabularies drift apart. */
141
+ const HEAD_PART_STATUS = {
142
+ present: PART_STATUS.present,
143
+ missing: PART_STATUS.missing,
144
+ stale: PART_STATUS.stale,
145
+ } as const satisfies Record<HeadStatus, PartStatus>
146
+
147
+ const headPart = (markdown: string, language: PlanLanguage): PartFinding => {
148
+ const { status, line, message } = checkPlanHead(markdown, language)
149
+ return finding(CONTRACT_PART.head, HEAD_PART_STATUS[status], line, message)
150
+ }
129
151
 
130
152
  /**
131
153
  * What a plan holds of the contract the herd, the watcher, `jh check` and a lane start read: phase headings,
@@ -139,6 +161,7 @@ export const contractOf = (markdown: string, language: PlanLanguage): PlanContra
139
161
  tasksPart(markdown, language, headings),
140
162
  modelPart(markdown, language, headings),
141
163
  dependenciesPart(markdown, language, headings),
164
+ headPart(markdown, language),
142
165
  ]
143
166
  return { parts, met: !parts.some(isGap) }
144
167
  }
@@ -36,6 +36,7 @@ export * from './model-column'
36
36
  export * from './model-table'
37
37
  export * from './phase-tally'
38
38
  export * from './phases'
39
+ export * from './plan-head'
39
40
  export * from './plan-structure'
40
41
  export * from './progress'
41
42
  export * from './short-names'
@@ -0,0 +1,131 @@
1
+ import { HEAD_LINE, type HeadLine, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
+ import { escapeRegExp } from './markdown'
3
+ import { parsePlanPhases } from './phases'
4
+
5
+ /** What a plan holds of its head: all of it and current, none of it, or something that no longer matches the plan. */
6
+ export const HEAD_STATUS = { present: 'present', missing: 'missing', stale: 'stale' } as const
7
+
8
+ export type HeadStatus = (typeof HEAD_STATUS)[keyof typeof HEAD_STATUS]
9
+
10
+ /** `line` is the 1-based line of the head's heading, or of its `Phase x/y` line; null when the plan has no head. */
11
+ export type HeadVerdict = { status: HeadStatus; line: number | null; message: string }
12
+
13
+ /**
14
+ * The `Phase x/y` of the status line: `current` is the id of the running phase as its heading spells it (`3`, `3a`),
15
+ * `total` the number of phases.
16
+ */
17
+ export type HeadPhase = { current: string; total: number }
18
+
19
+ export type PlanHead = {
20
+ /** 1-based line of the `## Für dich` heading. */
21
+ line: number
22
+ /** The text after each label found. */
23
+ texts: Partial<Record<HeadLine, string>>
24
+ /** 1-based line of each label found. */
25
+ lineNumbers: Partial<Record<HeadLine, number>>
26
+ /** The labels the head lacks, in head order. */
27
+ missing: HeadLine[]
28
+ phase: HeadPhase | null
29
+ /** True when another level-2 section stands between the header lines and the head. */
30
+ misplaced: boolean
31
+ }
32
+
33
+ type PhaseState = { id: string; open: boolean }
34
+
35
+ const SECTION_HEADING = /^##\s/
36
+ const HEAD_ENTRY = /^\s*(?:[-*]\s+)?\*\*([^*]+?)\s*:?\*\*\s*:?\s*(.*)$/
37
+ const HEAD_LINES = Object.values(HEAD_LINE)
38
+
39
+ const labelOf = (language: PlanLanguage, line: HeadLine): string => PLAN_VOCABULARY[language].head[line].toLowerCase()
40
+
41
+ const entryOf = (text: string, language: PlanLanguage): { label: HeadLine; text: string } | null => {
42
+ const match = text.match(HEAD_ENTRY)
43
+ if (!match) return null
44
+ const written = match[1].trim().toLowerCase()
45
+ const label = HEAD_LINES.find((each) => labelOf(language, each) === written)
46
+ return label ? { label, text: match[2].trim() } : null
47
+ }
48
+
49
+ const phaseOf = (statusText: string | undefined, language: PlanLanguage): HeadPhase | null => {
50
+ const match = statusText?.match(
51
+ new RegExp(`${escapeRegExp(PLAN_VOCABULARY[language].phase)}\\s+(\\d+[a-z]?)\\s*/\\s*(\\d+)`, 'i'),
52
+ )
53
+ return match ? { current: match[1].toLowerCase(), total: Number(match[2]) } : null
54
+ }
55
+
56
+ /**
57
+ * Reads the head section (`## Für dich` / `## For you`) and its five lines: `- **<label>:** <text>`, also with the
58
+ * colon outside the bold or without the list mark. Null when the plan has no such section.
59
+ */
60
+ export const parsePlanHead = (markdown: string, language: PlanLanguage): PlanHead | null => {
61
+ const lines = markdown.split('\n')
62
+ const heading = new RegExp(`^##\\s+${escapeRegExp(PLAN_VOCABULARY[language].sections.forYou)}\\s*$`)
63
+ const start = lines.findIndex((text) => heading.test(text))
64
+ if (start === -1) return null
65
+ const rest = lines.slice(start + 1)
66
+ const end = rest.findIndex((text) => SECTION_HEADING.test(text))
67
+ const texts: Partial<Record<HeadLine, string>> = {}
68
+ const lineNumbers: Partial<Record<HeadLine, number>> = {}
69
+ for (const [offset, text] of (end === -1 ? rest : rest.slice(0, end)).entries()) {
70
+ const entry = entryOf(text, language)
71
+ if (entry && texts[entry.label] === undefined) {
72
+ texts[entry.label] = entry.text
73
+ lineNumbers[entry.label] = start + offset + 2
74
+ }
75
+ }
76
+ return {
77
+ line: start + 1,
78
+ texts,
79
+ lineNumbers,
80
+ missing: HEAD_LINES.filter((each) => texts[each] === undefined),
81
+ phase: phaseOf(texts[HEAD_LINE.status], language),
82
+ misplaced: lines.slice(0, start).some((text) => SECTION_HEADING.test(text)),
83
+ }
84
+ }
85
+
86
+ const verdict = (status: HeadStatus, line: number | null, message: string): HeadVerdict => ({ status, line, message })
87
+
88
+ /** The phases a plan has, once each by id, with whether anything in them is still open. */
89
+ const phaseStates = (markdown: string, language: PlanLanguage): PhaseState[] =>
90
+ parsePlanPhases(markdown, language)
91
+ .filter(({ id }, index, all) => all.findIndex((other) => other.id === id) === index)
92
+ .map(({ id, items }) => ({ id: id.toLowerCase(), open: items.some(({ done }) => !done) }))
93
+
94
+ /** Compares the phase the head names with the plan's checkboxes; null when they agree. */
95
+ const phaseMismatch = ({ current, total }: HeadPhase, states: readonly PhaseState[]): string | null => {
96
+ if (total !== states.length) return `names ${total} phases, the plan has ${states.length}`
97
+ const running = states.find(({ open }) => open)
98
+ if (running === undefined)
99
+ return current === states.at(-1)?.id ? null : `all phases are done, the head names phase ${current}`
100
+ return current === running.id ? null : `names phase ${current}, the running phase is ${running.id}`
101
+ }
102
+
103
+ /**
104
+ * The verdict on a plan's head. Stale when it stands behind another section, lacks a line, has no `Phase x/y` in the
105
+ * status line, or names a phase other than the first one with an open checkbox (the last one when all are done) or a
106
+ * wrong phase count. Pure: it reads only the Markdown.
107
+ */
108
+ export const checkPlanHead = (markdown: string, language: PlanLanguage): HeadVerdict => {
109
+ const head = parsePlanHead(markdown, language)
110
+ const { sections } = PLAN_VOCABULARY[language]
111
+ if (head === null) return verdict(HEAD_STATUS.missing, null, `no \`## ${sections.forYou}\` section`)
112
+ if (head.misplaced)
113
+ return verdict(
114
+ HEAD_STATUS.stale,
115
+ head.line,
116
+ `the head must stand right after the header lines, before the other sections`,
117
+ )
118
+ if (head.missing.length > 0)
119
+ return verdict(
120
+ HEAD_STATUS.stale,
121
+ head.line,
122
+ `no line ${head.missing.map((each) => `"${PLAN_VOCABULARY[language].head[each]}"`).join(', ')}`,
123
+ )
124
+ // Narrows for the type checker: the status line exists once no label is missing.
125
+ const statusLine = head.lineNumbers[HEAD_LINE.status] ?? head.line
126
+ if (head.phase === null) return verdict(HEAD_STATUS.stale, statusLine, 'the status line has no "Phase x/y"')
127
+ const mismatch = phaseMismatch(head.phase, phaseStates(markdown, language))
128
+ return mismatch === null
129
+ ? verdict(HEAD_STATUS.present, head.line, `Phase ${head.phase.current}/${head.phase.total}`)
130
+ : verdict(HEAD_STATUS.stale, statusLine, mismatch)
131
+ }
@@ -8,7 +8,14 @@ import type { TaskPhase } from './current-task'
8
8
  * A task an agent is working on: its index among the phase's checkboxes, who works on it (null while only one agent
9
9
  * is active in the lane), whether the herd only guesses it from words, and when the agent began it (null for a guess).
10
10
  */
11
- export type ActiveTask = { index: number; agent: string | null; guessed: boolean; startedAt: number | null }
11
+ export type ActiveTask = {
12
+ index: number
13
+ agent: string | null
14
+ guessed: boolean
15
+ startedAt: number | null
16
+ /** The first open task of a running phase that no agent reported: drawn `◌`, but shimmering like a reported one. */
17
+ fromPlan?: true
18
+ }
12
19
 
13
20
  /** A task an agent reports that no open task of the plan names. */
14
21
  export type UnplannedTask = { text: string; agent: string | null }