jackherd 1.10.0 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +24 -16
  3. package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
  4. package/dist/config.d.ts +60 -0
  5. package/dist/config.js +39 -4
  6. package/dist/plan-format.d.ts +7 -1
  7. package/dist/plan-format.js +4 -2
  8. package/docs/follow-dark.svg +48 -0
  9. package/docs/follow.svg +48 -0
  10. package/herdr-plugin/herdr-plugin.toml +1 -1
  11. package/install.sh +1 -1
  12. package/kit/hooks/hooks/open-question/run.ts +8 -3
  13. package/kit/rules/rules/kit/pull-requests.md +2 -0
  14. package/kit/workflow/skills/follow/SKILL.md +3 -3
  15. package/kit/workflow/skills/orchestrate/SKILL.md +11 -3
  16. package/package.json +2 -2
  17. package/recipes/sources.md +4 -0
  18. package/schemas/jackherd.schema.json +80 -0
  19. package/src/cli.ts +1 -0
  20. package/src/config/schema.ts +24 -1
  21. package/src/config/tones.ts +27 -0
  22. package/src/lane-prompt/additions.ts +14 -0
  23. package/src/lane-prompt/command.ts +8 -2
  24. package/src/lane-prompt/input.ts +44 -3
  25. package/src/lane-prompt/options.ts +2 -0
  26. package/src/mermaid/command.ts +36 -0
  27. package/src/mermaid/problems.ts +48 -0
  28. package/src/plan-format/dependencies.ts +2 -2
  29. package/src/plan-format/waiting.ts +15 -2
  30. package/src/plans/appointment-rows.ts +34 -10
  31. package/src/plans/ask-coordinator.ts +46 -12
  32. package/src/plans/ask-target.ts +13 -2
  33. package/src/plans/board-close.ts +85 -0
  34. package/src/plans/frame.ts +5 -12
  35. package/src/plans/git.ts +35 -0
  36. package/src/plans/groomed.ts +27 -0
  37. package/src/plans/layout-phase.ts +7 -4
  38. package/src/plans/layout.ts +258 -48
  39. package/src/plans/link-chips.ts +22 -4
  40. package/src/plans/live.ts +73 -5
  41. package/src/plans/next-rows.ts +44 -0
  42. package/src/plans/planned.ts +22 -0
  43. package/src/plans/row-actions.ts +52 -0
  44. package/src/plans/row-focus.ts +148 -0
  45. package/src/plans/row-targets.ts +7 -0
  46. package/src/plans/selection.ts +14 -1
  47. package/src/plans/settings.ts +10 -0
  48. package/src/plans/snapshot.ts +79 -21
  49. package/src/plans/source-asked.ts +19 -0
  50. package/src/plans/source-prefix.ts +17 -0
  51. package/src/plans/source-rows.ts +188 -0
  52. package/src/plans/texts.ts +28 -3
  53. package/src/plans/tones.ts +30 -0
  54. package/src/plans/view.ts +8 -2
  55. package/src/plans/waiting-click.ts +129 -24
  56. package/src/terminal/text.ts +8 -0
  57. package/src/terminal/types.ts +4 -0
  58. package/src/vocabulary/index.ts +10 -0
  59. package/src/watch/plan-badges-read.ts +9 -2
  60. package/src/watch/plan-badges.ts +10 -2
  61. package/src/watch/report-questions.ts +4 -3
  62. package/src/watch/source-entries.ts +55 -0
  63. package/src/watch/source-fields.ts +1 -0
  64. package/src/watch/source-statuses.ts +60 -0
  65. package/src/watch/source-tickets.ts +21 -1
  66. package/src/watch/sources.ts +41 -1
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.10.0"
3
+ version = "1.11.1"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the herd, the state of a board's lanes, in a herdr pane"
6
6
  platforms = ["linux", "macos"]
package/install.sh CHANGED
@@ -9,7 +9,7 @@ set -eu
9
9
 
10
10
  # The default is the version this file ships with. The release bump changes it together with
11
11
  # package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
12
- JACKHERD_VERSION="${JACKHERD_VERSION:-1.10.0}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.11.1}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -28,7 +28,8 @@ export const relevantLines = (text: string): string => {
28
28
  * Records the questions of the session's dialogs that ended without an answer, and closes the open records of every
29
29
  * session that a dialog in this transcript has answered since. The whole transcript is read, not the last turn: a
30
30
  * rejected dialog ends its turn without a Stop hook, so it is found at the end of the next turn. Claude Code's hook
31
- * input: `cwd`, `session_id`, `transcript_path`. Writes nothing on any failure.
31
+ * input: `cwd`, `session_id`, `transcript_path`. A question whose text has an open record already adds none, so asking
32
+ * it again and putting it off again leaves one. Writes nothing on any failure.
32
33
  */
33
34
  export const runOpenQuestion = (input: unknown, dependencies: OpenQuestionDependencies): void => {
34
35
  try {
@@ -44,8 +45,12 @@ export const runOpenQuestion = (input: unknown, dependencies: OpenQuestionDepend
44
45
  if (answer) dependencies.writeRecord(main, { ...record, closedAt: answer.answeredAt })
45
46
  }
46
47
  const known = new Set(stored.map(({ id }) => id))
47
- for (const record of openQuestionsOf(entries, { cwd, session, answered, idOf: dependencies.idOf }))
48
- if (!known.has(record.id)) dependencies.writeRecord(main, record)
48
+ const openTexts = new Set(stored.filter(({ closedAt }) => closedAt === null).map(({ question }) => question))
49
+ for (const record of openQuestionsOf(entries, { cwd, session, answered, idOf: dependencies.idOf })) {
50
+ if (known.has(record.id) || openTexts.has(record.question)) continue
51
+ openTexts.add(record.question)
52
+ dependencies.writeRecord(main, record)
53
+ }
49
54
  } catch {
50
55
  // A failed record is no record; the herd shows no question.
51
56
  }
@@ -42,6 +42,8 @@ Add a fenced ` ```mermaid ` block **when it removes reader effort**:
42
42
 
43
43
  Keep each diagram to one idea; two small diagrams beat one dense one. Small or single-file PRs need none.
44
44
 
45
+ Write edge labels as `-->|"label"|` (`-- text -->` breaks on a `--` in it); `jh mermaid <body file>` must exit 0 before `gh pr create` or `gh pr edit`.
46
+
45
47
  ## Review order (large PRs)
46
48
 
47
49
  When a PR spans many files, add a short **suggested review order** — **pure/domain logic first, adapters next, UI and wiring last**. A `| # | Area | Start here | Why first |` table works well.
@@ -15,8 +15,8 @@ You add one entry to `watch.sources`, so that `jh watch` reports a new or change
15
15
  - **Service:** the services the recipe index lists, and "another command that prints JSON".
16
16
  - **What to follow:** the item the argument names (its comments, recommended) or a query over several items.
17
17
  - **Lane:** the lane the reports name, from the board's lanes table, or none.
18
- - **Ticket:** whether the entries are tickets (`ticket: true`, recommended for an issue tracker or a project board), which the herd shows as a badge beside the plan of their lane: the lane above, or the lane whose name, board row or plan names the ticket's id. A source of comments or messages is no ticket. The path of the status you find yourself in step 3.
19
- - **What reports:** a change of what the user reads (title, body, the latest comment; recommended), or a change of named fields such as a status (`reportOn` with those fields, mapped in `map`).
18
+ - **Ticket:** whether the entries are tickets (`ticket: true`, recommended for an issue tracker or a project board), which the herd shows as a badge beside the plan of their lane: the lane above, or the lane whose name, board row or plan names the ticket's id. A source of comments or messages is no ticket; it shows in the herd's `Sources` block instead (a click opens the entry at `map.url`). The path of the status you find yourself in step 3.
19
+ - **Tone:** for a non-ticket source, the colour of its `Sources` mark while its report is new (`tone`: amber, teal, grey, red, green, blue, magenta); recommended: no `tone`.- **What reports:** a change of what the user reads (title, body, the latest comment; recommended), or a change of named fields such as a status (`reportOn` with those fields, mapped in `map`).
20
20
  - **Own changes:** ignore the user's own account on that service (`ignoreAuthors`, needs an author in the map), or report everything.
21
21
 
22
22
  ## 2. Check, read only
@@ -30,7 +30,7 @@ Anything missing goes to the user as the command to run themselves (`! <command>
30
30
 
31
31
  Before filling, read the help of the installed CLI version for every command the recipe calls (`acli jira workitem search --help` and `workitem view --help`, `gh issue list --help`) and confirm that the fields and options the entry needs exist there. Read the provider's web documentation only when the help leaves it open or no recipe fits. A recipe is a starting point; the installed version decides.
32
32
 
33
- Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane`, `ignoreAuthors` and `ticket` where chosen. For a ticket source, find the path of the status in the output (from the CLI's help, or the dry run with `--raw`) and set it as `map.status`; the entry you show for approval in step 4 carries it, and without a status in the output the entry has none and the badge shows only the key. A recipe that needs a script (a token from the environment, or one call per item) gets TypeScript run by Bun: write it, from the recipe's script under `recipes/sources/`, to the scratchpad first as `<name>.ts`, never into the repository before step 4, and let the entry's `command` be `["bun", "scripts/sources/<name>.ts"]`. Script and direct command follow the index's **Rules every recipe follows**.
33
+ Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane`, `ignoreAuthors`, `ticket` and `tone` where chosen, and a short `label` (e.g. `Error log · Checkout`; it prefixes the source's `Waiting for you` rows). For a ticket source, find the path of the status in the output (from the CLI's help, or the dry run with `--raw`) and set it as `map.status`; the entry you show for approval in step 4 carries it, and without a status in the output the entry has none and the badge shows only the key. A recipe that needs a script (a token from the environment, or one call per item) gets TypeScript run by Bun: write it, from the recipe's script under `recipes/sources/`, to the scratchpad first as `<name>.ts`, never into the repository before step 4, and let the entry's `command` be `["bun", "scripts/sources/<name>.ts"]`. Script and direct command follow the index's **Rules every recipe follows**.
34
34
 
35
35
  Write the entry as one JSON object to a draft file in the scratchpad and run, from the main checkout:
36
36
 
@@ -67,7 +67,7 @@ Each file under `references/` is read when its trigger fires: the step or the ev
67
67
  `jh lane prompt <lane> [--board <board>]` writes the lane prompt from the lane's board row and its plan, in
68
68
  the plan's language, to `.claude/handover/<lane>-prompt.md` and prints the path. It opens with a first line
69
69
  that names the lane, which the hooks and the project's rules read: keep that line as it is. Before you send
70
- it, add to the file with `Edit` the open questions that touch the lane and what the project
70
+ it, add to the file with `Edit`, below the marker line at its end (it stays through every new writing), the open questions that touch the lane and what the project
71
71
  supplement adds to the lane prompt. Never paste the rules of this skill into a lane prompt. The lane asks you in text blocks at the end of its turn; answer them with
72
72
  `herdr agent prompt` (§ 2 step 8).
73
73
 
@@ -248,7 +248,7 @@ On wake-up:
248
248
  | `checks passed #<n>` | One line to the user: ready to merge, with the board's merge order |
249
249
  | `deploy …` | Result and target as the run states them, to the user; a target `unknown (…)` is reported as unknown, never guessed. A failed run goes to the user as an `AskUserQuestion`: read the logs now or later |
250
250
  | `due <when> · <action>` | An open appointment fell due (**An agreed time**, below); the action is cut to 160 characters, the board row holds it whole. Carry it out as agreed, under the approvals it needs (§ 5), then write the outcome into its status cell in the same turn |
251
- | `heartbeat` | One line of status to the user, also when nothing changed; a resting lane carries `waits on: <question>, <n> open questions` from its report, and `for you (<n>): …` names what still waits on the user (**Anything that waits on the user's answer**, below): remind the user of those items in that line |
251
+ | `heartbeat` | One line of status to the user, also when nothing changed; a resting lane carries `waits on: <question>, <n> open questions` from its report, and `for you (<n>): …` names what still waits on the user (**Anything that waits on the user's answer**, below): the `for you` part of that line is the count only (`3 wait for you, see the herd`, in the language of the replies); the herd shows the items, and no reply of yours ends with a list of the rows of the board section |
252
252
  | `gh unavailable` | Tell the user once; herdr watching continues |
253
253
  | `lane <name> warn` | One line to the user: the lane's fill and that it hands over at a phase boundary if it rises further; no step in the lane |
254
254
  | `lane <name> alert` | § 8: send the lane once the request for its hand-over at the end of its current phase |
@@ -290,10 +290,18 @@ watcher reports an open row once when it falls due (`due`, above); an empty stat
290
290
  and a reworded or moved row is a new appointment and reports again. Close a reported row through its
291
291
  status cell.
292
292
 
293
+ **Board grooming** is an appointment of its own, a row in that section whose action starts with
294
+ `Board-Pflege` (`Board grooming`); a board without one gets it, due a week from now. When it reports:
295
+ check that the open lane rows still serve the board's goal (a mismatch joins the bundled question), rewrite the
296
+ board's `Als Nächstes` (`Next`) section (three lines at most, each with a date), clear finished lane
297
+ rows, ask for outdated plans in one bundled question, then set its `When` a week after now and leave its status
298
+ empty: this is the one appointment that is moved, not closed. The herd
299
+ counts `groomed <n>d ago` from it and turns it amber at eight days.
300
+
293
301
  **Anything that waits on the user's answer** (a preview or a file sent to them, a question put to them, a
294
302
  report handed over for acceptance) goes into the board in the same turn you send it, in the section
295
303
  `## Wartet auf dich` (German plans) or `## Waiting for you` (English), one row `Since | Item | Origin | Status`
296
- (`Seit | Punkt | Herkunft | Stand`) with the time as in **An agreed time**. It survives a hand-over, which a
304
+ (`Seit | Punkt | Herkunft | Stand`) with the time as in **An agreed time**; for a source's report the origin is the source's name, and the herd prefixes the row with its label. It survives a hand-over, which a
297
305
  message in this chat does not. Close a row in its status cell in the turn the user's answer arrives (also a
298
306
  "no"), or when the thing is withdrawn; an empty status counts as open. A file sent with `SendUserFile` and an
299
307
  Artifact you publish need no row (the kit hook `sent-file` records each): close them after the answer with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -50,7 +50,6 @@
50
50
  "!docs/gen",
51
51
  "assets",
52
52
  "!assets/icons/build.ts",
53
- "!assets/icons/version.ts",
54
53
  "!assets/branding/outline.ts",
55
54
  "recipes",
56
55
  "README.md",
@@ -68,6 +67,7 @@
68
67
  "dev": "tsup --watch",
69
68
  "clean": "rm -rf dist",
70
69
  "gen": "bun scripts/generate-schema.ts",
70
+ "pin:assets": "bun scripts/pin-readme-assets.ts --write",
71
71
  "lint": "biome check .",
72
72
  "format": "biome format --write .",
73
73
  "test": "bun test"
@@ -60,6 +60,10 @@ A run that ends with an exit code other than 0, runs past 30 seconds (then its w
60
60
 
61
61
  **Show a ticket beside its plan.** `"ticket": true` marks a source's entries as tickets. The herd then shows each one as a badge in the 📎 line of its lane's plan, right after `📄 Plan`: `📡 T-123 · In Progress`, linked to the ticket. A ticket belongs to the source's `lane`, and also to any lane whose board row, name or plan names its id as a whole word (`T-12` is not found in `T-123`); so one source listing all your open tickets works without `lane`. `map.status` is the status the badge shows; without it the badge shows only the key. A ticket whose last reported change is less than a day old is amber, and a folded lane then shows `📡 <n>` in amber before its bar, linked to the first new ticket. A lane shows at most three tickets, new ones first, then `+<n>`; a link chip of the plan with the same URL gives way to the badge. For this the saved state keeps, of a marked source only, each entry of its last successful run with its URL, its status (stored like a `reportOn` value) and the time its last change was reported. The badges come from the watcher's state like the pull request badges: without a running watcher or with a state older than three minutes there are none, and the header says `watcher off` or `state 4 min old`, also without a pull request. The [GitHub Projects recipe](#user-content-recipes) is a ticket source with a status.
62
62
 
63
+ **Show a source in the herd.** A source without `ticket` appears in the herd's `Sources` block, one row each: its name, when it last ran (`ran 12 min ago`), since when it fails (`failing since 3 h ago`, mark red) and its newest report (mark amber while that report is under a day old). A source with several entries is one folded row; a click opens its entries (up to five, then `+<n> more`), each with its title, `map.count` as `113×` and the time of its last change. A click on an entry opens its `url` (the watcher keeps it only as a checked `https:` URL of at most 200 characters, so map a short link), `[ask]` asks the coordinator to look at that entry. An entry that an open row of "Waiting for you" already asks about (the row's `Origin` names the source and its text the title) is dim and says `↑ asked`; the row is amber only for a new entry nobody asked about. The watcher keeps the last 20 entries of such a source. `tone` (amber, teal, grey, red, green, blue or magenta) sets the mark's colour while the report is new, instead of the tone of the role `new`; the roles `waiting`, `new`, `running` and `resting` take their tones from `plans.tones` in the config (defaults: amber, amber, teal, grey).
64
+
65
+ **Name a source in "Waiting for you".** A row of the board's waiting section whose `Origin` cell is the name of a source (case ignored) shows `<label>: <row>` in the herd; `label` (one line, at most 40 characters) is optional, without it the source's name stands before the row. `/jh-follow` proposes a short label when it writes the entry.
66
+
63
67
  **How you hear about it.** The report goes to the coordinator, as a `=== source …` event of its watcher. It passes you one line with the entry, its gist and the link. A decision inside it, such as a product owner's answer, it takes over only after you confirmed it, since the text comes from outside. You hear of it only while the coordinator's session and its watcher run; what happened in between is reported at the next start. There is no desktop notification of its own: you get the one herdr shows for any lane that needs you. What a source reports is a change in the mapped content fields (`title`, `author`, `body`, `url`), or with `reportOn` a change of the fields it names, such as a status.
64
68
 
65
69
  **Try a source before the watcher runs it.** `jh watch source <name> --dry-run` runs a configured source once; a draft not yet in the config comes as a file (`jh watch source draft.json --dry-run`, one source object) or on stdin (`-`). It lists the entries (`<id> · <author> · <title>`), says that the watcher's first run of the source is a baseline and reports nothing, and shows one entry as a later report would print it; it neither reads nor changes the watcher's state. A field of `map` that is empty in every entry is named with its path (`map.updatedAt: in 23 of 23 entries empty (.fields.updated)`), read from the raw output, so a run that failed on a missing `id` or `updatedAt` names it too; the dry run then ends with exit code 1, as for a failed run, and `/jh-follow` does not write the source. Without entries it says `0 entries: map not checked`. `--raw` adds the command's output, for finding the paths of `items` and `map`:
@@ -297,6 +297,65 @@
297
297
  }
298
298
  },
299
299
  "additionalProperties": false
300
+ },
301
+ "tones": {
302
+ "default": {},
303
+ "type": "object",
304
+ "properties": {
305
+ "waiting": {
306
+ "default": "amber",
307
+ "type": "string",
308
+ "enum": [
309
+ "amber",
310
+ "teal",
311
+ "grey",
312
+ "red",
313
+ "green",
314
+ "blue",
315
+ "magenta"
316
+ ]
317
+ },
318
+ "new": {
319
+ "default": "amber",
320
+ "type": "string",
321
+ "enum": [
322
+ "amber",
323
+ "teal",
324
+ "grey",
325
+ "red",
326
+ "green",
327
+ "blue",
328
+ "magenta"
329
+ ]
330
+ },
331
+ "running": {
332
+ "default": "teal",
333
+ "type": "string",
334
+ "enum": [
335
+ "amber",
336
+ "teal",
337
+ "grey",
338
+ "red",
339
+ "green",
340
+ "blue",
341
+ "magenta"
342
+ ]
343
+ },
344
+ "resting": {
345
+ "default": "grey",
346
+ "type": "string",
347
+ "enum": [
348
+ "amber",
349
+ "teal",
350
+ "grey",
351
+ "red",
352
+ "green",
353
+ "blue",
354
+ "magenta"
355
+ ]
356
+ }
357
+ },
358
+ "additionalProperties": false
300
359
  }
301
360
  },
302
361
  "additionalProperties": false
@@ -483,6 +542,9 @@
483
542
  },
484
543
  "status": {
485
544
  "type": "string"
545
+ },
546
+ "count": {
547
+ "type": "string"
486
548
  }
487
549
  },
488
550
  "required": [
@@ -506,6 +568,12 @@
506
568
  "minimum": 1,
507
569
  "maximum": 9007199254740991
508
570
  },
571
+ "label": {
572
+ "type": "string",
573
+ "minLength": 1,
574
+ "maxLength": 40,
575
+ "pattern": "^[^\\r\\n]+$"
576
+ },
509
577
  "lane": {
510
578
  "type": "string",
511
579
  "minLength": 1
@@ -513,6 +581,18 @@
513
581
  "ticket": {
514
582
  "type": "boolean"
515
583
  },
584
+ "tone": {
585
+ "type": "string",
586
+ "enum": [
587
+ "amber",
588
+ "teal",
589
+ "grey",
590
+ "red",
591
+ "green",
592
+ "blue",
593
+ "magenta"
594
+ ]
595
+ },
516
596
  "reportOn": {
517
597
  "minItems": 1,
518
598
  "maxItems": 10,
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ export const COMMANDS: CommandTable = {
25
25
  init: () => import('./sync/init-command'),
26
26
  [HERD_COMMAND.alias]: () => import('./plans/command'),
27
27
  lane: () => import('./lane/command'),
28
+ mermaid: () => import('./mermaid/command'),
28
29
  preview: () => import('./preview/command'),
29
30
  settled: () => import('./settled/command'),
30
31
  sync: () => import('./sync/command'),
@@ -10,6 +10,7 @@ import { BUN_COMMANDS, DEFAULT_BASE_BRANCH } from './detect'
10
10
  import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
11
11
  import { hasNestedQuantifier } from './nested-quantifier'
12
12
  import { isRepositoryDirectory } from './repository-directory'
13
+ import { DEFAULT_TONES, TONE_NAMES } from './tones'
13
14
 
14
15
  export const TERMINAL_ADAPTER = { none: 'none', herdr: 'herdr' } as const
15
16
 
@@ -176,7 +177,7 @@ const sourcePath = z.string().refine(isSourcePath, 'not a source path: `.`, or `
176
177
 
177
178
  const SOURCE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
178
179
 
179
- /** The seven known fields, and any further field (`priority`, `assignee`, …) whose name is a plain name, for `reportOn`. */
180
+ /** The eight known fields, and any further field (`priority`, `assignee`, …) whose name is a plain name, for `reportOn`. */
180
181
  const sourceMapSchema = z
181
182
  .object({
182
183
  [SOURCE_FIELD.id]: sourcePath,
@@ -193,6 +194,8 @@ const sourceMapSchema = z
193
194
  [SOURCE_FIELD.author]: sourcePath.optional(),
194
195
  /** A ticket source's status, shown on its badge in the herd; also a field `reportOn` can name. */
195
196
  [SOURCE_FIELD.status]: sourcePath.optional(),
197
+ /** How often the entry happened (an error count), shown as `113×` beside the entry in the herd's `Sources` block. */
198
+ [SOURCE_FIELD.count]: sourcePath.optional(),
196
199
  })
197
200
  .catchall(sourcePath)
198
201
  .refine((map) => Object.keys(map).every((field) => SOURCE_FIELD_NAME.test(field)), {
@@ -203,6 +206,8 @@ const sourceMapSchema = z
203
206
  const canChange = ({ map, reportOn }: { map: Record<string, string | undefined>; reportOn?: string[] }): boolean =>
204
207
  map.updatedAt !== undefined || CONTENT_FIELDS.some((field) => map[field] !== undefined) || reportOn !== undefined
205
208
 
209
+ const toneName = z.enum(TONE_NAMES)
210
+
206
211
  const sourceSchema = z
207
212
  .strictObject({
208
213
  /** Printed in every report of the source and the key of its saved state. */
@@ -218,6 +223,13 @@ const sourceSchema = z
218
223
  ignoreAuthors: z.array(z.string().min(1)).default([]),
219
224
  /** The most new or changed entries one report shows of this source; the rest is one line `+<n> more`. */
220
225
  reportLimit: z.number().int().min(1).default(5),
226
+ /** Shown before a row of "Waiting for you" that came from this source (`Error log · Checkout: …`); without it the source's name. */
227
+ label: z
228
+ .string()
229
+ .min(1)
230
+ .max(40)
231
+ .regex(/^[^\r\n]+$/, 'one line')
232
+ .optional(),
221
233
  /** The lane the source's reports name. */
222
234
  lane: z.string().min(1).optional(),
223
235
  /**
@@ -225,6 +237,8 @@ const sourceSchema = z
225
237
  * the lane above or the lane whose board row or plan names its id.
226
238
  */
227
239
  ticket: z.boolean().optional(),
240
+ /** The tone of the source's mark in the herd's `Sources` block while its newest report is new; without it the tone of the role `new`. */
241
+ tone: toneName.optional(),
228
242
  /**
229
243
  * The mapped fields whose change reports, with the old and the new value, in place of the content of `title`,
230
244
  * `author`, `body` and `url`; without it a change of that content reports.
@@ -383,6 +397,13 @@ const hintsSchema = z.strictObject({
383
397
  /** Minutes a lane works without a task signal before the watcher reports `no task signal`. */
384
398
  const DEFAULT_TASK_SIGNAL_MINUTES = 10
385
399
 
400
+ const tonesSchema = z.strictObject({
401
+ waiting: toneName.default(DEFAULT_TONES.waiting),
402
+ new: toneName.default(DEFAULT_TONES.new),
403
+ running: toneName.default(DEFAULT_TONES.running),
404
+ resting: toneName.default(DEFAULT_TONES.resting),
405
+ })
406
+
386
407
  const contextSchema = z
387
408
  .strictObject({
388
409
  /** Tokens in a lane's context from which its fill shows yellow and `/implement` offers a new chat. */
@@ -475,6 +496,8 @@ export const jackherdConfigSchema = z.strictObject({
475
496
  )
476
497
  .default(DEFAULT_LINK_KINDS.map((kind) => ({ ...kind }))),
477
498
  context: contextSchema.prefault({}),
499
+ /** The colour of each role in the herd, by name: what waits for you, what is new, what runs, what rests. */
500
+ tones: tonesSchema.prefault({}),
478
501
  })
479
502
  .prefault({}),
480
503
  /**
@@ -0,0 +1,27 @@
1
+ /** The colours the herd paints with by name; the terminal's own palette decides how each looks, `grey` is the dimmed text colour. */
2
+ export const TONE = {
3
+ amber: 'amber',
4
+ teal: 'teal',
5
+ grey: 'grey',
6
+ red: 'red',
7
+ green: 'green',
8
+ blue: 'blue',
9
+ magenta: 'magenta',
10
+ } as const
11
+
12
+ export type ToneName = (typeof TONE)[keyof typeof TONE]
13
+
14
+ export const TONE_NAMES = Object.values(TONE)
15
+
16
+ /** What a tone is for in the herd: what waits for you, what is new, what runs, what rests. */
17
+ export const TONE_ROLE = { waiting: 'waiting', new: 'new', running: 'running', resting: 'resting' } as const
18
+
19
+ export type ToneRole = (typeof TONE_ROLE)[keyof typeof TONE_ROLE]
20
+
21
+ /** The tone of each role, as the herd looked before the tones were configurable. */
22
+ export const DEFAULT_TONES: Record<ToneRole, ToneName> = {
23
+ [TONE_ROLE.waiting]: TONE.amber,
24
+ [TONE_ROLE.new]: TONE.amber,
25
+ [TONE_ROLE.running]: TONE.teal,
26
+ [TONE_ROLE.resting]: TONE.grey,
27
+ }
@@ -0,0 +1,14 @@
1
+ /** The line that ends a written lane prompt; what the coordinator adds below it survives every new writing. */
2
+ export const ADDITIONS_MARKER = '<!-- jackherd: additions below this line stay when the prompt is written again -->'
3
+
4
+ /** What follows the marker line of a written prompt; empty for no file, no marker line or nothing below it. */
5
+ export const additionsOf = (existing: string | null): string => {
6
+ if (existing === null) return ''
7
+ const lines = existing.split('\n')
8
+ const marker = lines.indexOf(ADDITIONS_MARKER)
9
+ return marker === -1 ? '' : lines.slice(marker + 1).join('\n')
10
+ }
11
+
12
+ /** `prompt` closed by the marker and by the additions of the file it replaces, so writing twice changes nothing. */
13
+ export const withAdditions = (prompt: string, existing: string | null): string =>
14
+ `${prompt}\n${ADDITIONS_MARKER}\n${additionsOf(existing)}`
@@ -5,6 +5,7 @@ import { boardFor } from '../plans/board-pick'
5
5
  import { mainCheckout, worktreeRoot } from '../plans/git'
6
6
  import { boardRepoPathOf, readSettings, worktreeOf } from '../plans/settings'
7
7
  import { asksForHelp } from '../preview/options'
8
+ import { withAdditions } from './additions'
8
9
  import { buildLanePrompt } from './build'
9
10
  import { type BoardSource, type LanePromptRequest, lanePromptInputOf, type ReadPlan } from './input'
10
11
  import { LANE_PROMPT_USAGE, type LanePromptOptions, parseLanePromptOptions } from './options'
@@ -53,15 +54,19 @@ const promptSourcesOf = (options: LanePromptOptions, cwd: string): PromptSources
53
54
  }
54
55
  }
55
56
 
57
+ /** Writes `prompt` to `out`, closed by the marker and by the additions the file there already carries below it. */
56
58
  const writePrompt = (out: string, prompt: string): void => {
59
+ const existing = existsSync(out) ? readFileSync(out, 'utf8') : null
57
60
  mkdirSync(dirname(out), { recursive: true })
58
- writeFileSync(out, prompt)
61
+ writeFileSync(out, withAdditions(prompt, existing))
59
62
  }
60
63
 
61
64
  /**
62
65
  * `jh lane prompt <lane> [--board <board.md>] [--out <file>] [--handover <file>]`: writes the lane's prompt, built
63
66
  * from its board row and its plan in the plan language, and prints the file's path, so the coordinator sends it with
64
- * `herdr agent prompt <lane> "$(cat <file>)"`. `--help` prints the usage. Exit 0 written, 1 refused or failed.
67
+ * `herdr agent prompt <lane> "$(cat <file>)"`. Below the marker line at the file's end the coordinator adds what the
68
+ * plan does not say; it stays through every new writing. A phase two open rows name is in no prompt and is reported on
69
+ * stderr. `--help` prints the usage. Exit 0 written, 1 refused or failed.
65
70
  */
66
71
  export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
67
72
  if (asksForHelp(args)) {
@@ -76,6 +81,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
76
81
  console.error(result.reason)
77
82
  return 1
78
83
  }
84
+ for (const notice of result.notices ?? []) console.error(notice)
79
85
  const out = options.out === null ? defaultPromptFile(root, options.lane) : resolve(cwd, options.out)
80
86
  writePrompt(out, buildLanePrompt(result.input))
81
87
  console.log(out)
@@ -17,7 +17,10 @@ export type BoardSource = { text: string; directory: string; path: string }
17
17
  /** A plan's text by its path from the repository root; null when it cannot be read. */
18
18
  export type ReadPlan = (path: string) => string | null
19
19
 
20
- export type LanePromptResult = { kind: 'prompt'; input: LanePromptInput } | { kind: 'refused'; reason: string }
20
+ /** `notices` is absent unless the board needs fixing: a phase two open rows name is in neither prompt. */
21
+ export type LanePromptResult =
22
+ | { kind: 'prompt'; input: LanePromptInput; notices?: string[] }
23
+ | { kind: 'refused'; reason: string }
21
24
 
22
25
  type PlanSteps = { path: string; ids: string[] }
23
26
 
@@ -90,6 +93,37 @@ const plansOf = (
90
93
  return { plans }
91
94
  }
92
95
 
96
+ /** The other open lanes whose rows name the phase `id` of the plan at `path`, by name. */
97
+ const otherNamersOf = (others: readonly SprintLane[], directory: string, path: string, id: string): string[] =>
98
+ others
99
+ .filter((lane) => planStepsOf(lane, directory).some((steps) => steps.path === path && steps.ids.includes(id)))
100
+ .map((lane) => lane.name)
101
+
102
+ const withoutContested = (
103
+ plans: readonly LanePlan[],
104
+ lane: string,
105
+ others: readonly SprintLane[],
106
+ directory: string,
107
+ ): { plans: LanePlan[]; notices: string[] } => {
108
+ const judged = plans.map((plan) => ({
109
+ plan,
110
+ phases: plan.phases.map((phase) => ({ phase, namers: otherNamersOf(others, directory, plan.path, phase.id) })),
111
+ }))
112
+ const kept = judged.map(({ plan, phases }) => ({
113
+ ...plan,
114
+ phases: phases.filter(({ namers }) => namers.length === 0).map(({ phase }) => phase),
115
+ }))
116
+ const notices = judged.flatMap(({ plan, phases }) =>
117
+ phases
118
+ .filter(({ namers }) => namers.length > 0)
119
+ .map(
120
+ ({ phase, namers }) =>
121
+ `${plan.path} ${phaseLabel(phase.id)} is named by ${[lane, ...namers].sort().join(' and ')}, so it is in neither prompt; fix the board`,
122
+ ),
123
+ )
124
+ return { plans: kept.filter((plan) => plan.phases.length > 0), notices }
125
+ }
126
+
93
127
  /**
94
128
  * What the lane's prompt is built from: its open row of the board's lanes table (port, plans, phases), each named
95
129
  * phase that is not finished, and the write set of each from its plan's dependency table. A plan is read from the lane
@@ -103,7 +137,8 @@ export const lanePromptInputOf = (
103
137
  readPlan: ReadPlan,
104
138
  readLanePlan: ReadPlan = () => null,
105
139
  ): LanePromptResult => {
106
- const sprintLane = parseSprintLanes(board.text, request.language).find((candidate) => candidate.name === request.lane)
140
+ const lanes = parseSprintLanes(board.text, request.language)
141
+ const sprintLane = lanes.find((candidate) => candidate.name === request.lane)
107
142
  if (!sprintLane) return refused(`no row for lane ${request.lane} in ${board.path}`)
108
143
  if (sprintLane.closed) return refused(`lane ${request.lane} is closed in ${board.path}`)
109
144
  const steps = planStepsOf(sprintLane, board.directory)
@@ -112,5 +147,11 @@ export const lanePromptInputOf = (
112
147
  if ('reason' in found) return refused(found.reason)
113
148
  if (found.plans.length === 0) return refused(`every phase of lane ${request.lane} is finished`)
114
149
  const { lane, handover, language } = request
115
- return { kind: 'prompt', input: { language, lane, plans: found.plans, port: sprintLane.port, handover } }
150
+ const others = lanes.filter((candidate) => candidate.name !== lane && !candidate.closed)
151
+ const { plans, notices } = withoutContested(found.plans, lane, others, board.directory)
152
+ if (plans.length === 0) {
153
+ return refused(`every open phase of lane ${lane} is also named by another lane: ${notices.join('; ')}`)
154
+ }
155
+ const input = { language, lane, plans, port: sprintLane.port, handover }
156
+ return notices.length === 0 ? { kind: 'prompt', input } : { kind: 'prompt', input, notices }
116
157
  }
@@ -13,6 +13,8 @@ const SUBCOMMAND = 'prompt'
13
13
  export const LANE_PROMPT_USAGE = `usage: ${CLI_NAME} lane prompt <lane> [--board <board.md>] [--out <file>] [--handover <file>]
14
14
 
15
15
  Writes the prompt that starts the lane, from its row in the board and its plan, and prints the file's path.
16
+ What you add below the marker line at the end of the file stays when the prompt is written again.
17
+ A phase that two open rows of the board name is in neither prompt; the command reports it.
16
18
  --board <board.md> the board to read; without it the board is picked as for ${CLI_NAME} herd
17
19
  --out <file> where to write the prompt; default .claude/handover/<lane>-prompt.md in the main checkout
18
20
  --handover <file> a restart after a hand-over: the lane reads this file first and continues with its next step`
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { asksForHelp } from '../preview/options'
3
+ import { CLI_NAME } from '../version/release'
4
+ import { type MermaidProblem, mermaidProblems } from './problems'
5
+
6
+ const USAGE = `usage: ${CLI_NAME} mermaid <file.md>
7
+
8
+ Checks every \`\`\`mermaid block of the file for edge labels GitHub does not draw (a label between the dashes,
9
+ a pipe label without quotes). Run it on a pull request body before gh pr create or gh pr edit.
10
+ Exit 0 clean, 1 with problems or a wrong command line.`
11
+
12
+ /** One line per problem, `file:line: reason`, then the offending text indented. */
13
+ const problemLines = (file: string, problems: readonly MermaidProblem[]): string[] =>
14
+ problems.flatMap(({ line, reason, text }) => [`${file}:${line}: ${reason}`, ` ${text}`])
15
+
16
+ export const run = (args: string[]): number => {
17
+ if (asksForHelp(args)) {
18
+ console.log(USAGE)
19
+ return 0
20
+ }
21
+ const [file, ...rest] = args
22
+ if (file === undefined || rest.length > 0) {
23
+ console.error(USAGE)
24
+ return 1
25
+ }
26
+ let text: string
27
+ try {
28
+ text = readFileSync(file, 'utf8')
29
+ } catch {
30
+ console.error(`cannot read ${file}`)
31
+ return 1
32
+ }
33
+ const problems = mermaidProblems(text)
34
+ for (const line of problemLines(file, problems)) console.error(line)
35
+ return problems.length === 0 ? 0 : 1
36
+ }
@@ -0,0 +1,48 @@
1
+ /** A line of a Mermaid block that GitHub fails to draw: its 1-based line in the text, the line itself and why. */
2
+ export type MermaidProblem = { line: number; text: string; reason: string }
3
+
4
+ const FENCE = /^\s*(```|~~~)\s*(\S*)/
5
+ const FLOWCHART = /^\s*(?:flowchart|graph)\b/
6
+ /** A label written between the dashes of the link, `a -- text --> b`: a `--` inside the text ends the link early. */
7
+ const LABEL_BETWEEN_DASHES = /\s(?:--|==)\s+\S.*?\s(?:-->|---|==>)(?:\s|$)/
8
+ /** A pipe label that does not open with a quote: a comma, colon or parenthesis in it breaks the parser. */
9
+ const UNQUOTED_PIPE_LABEL = /(?:-->|---|-\.->|==>)\|[^"|]/
10
+
11
+ const REASON = {
12
+ betweenDashes: 'edge label between dashes: write -->|"label"|',
13
+ unquoted: 'edge label without quotes: write -->|"label"|',
14
+ } as const
15
+
16
+ const reasonOf = (text: string): string | null => {
17
+ if (LABEL_BETWEEN_DASHES.test(text)) return REASON.betweenDashes
18
+ return UNQUOTED_PIPE_LABEL.test(text) ? REASON.unquoted : null
19
+ }
20
+
21
+ /**
22
+ * The edge labels in the flowcharts of `markdown` that GitHub does not draw: ones written between the dashes and
23
+ * pipe labels without quotes. Only fenced blocks marked `mermaid` are read (a block never closed runs to the end of the
24
+ * text); the form that always parses is `-->|"label"|`.
25
+ */
26
+ export const mermaidProblems = (markdown: string): MermaidProblem[] => {
27
+ const problems: MermaidProblem[] = []
28
+ let fence: string | null = null
29
+ let isFlowchart = false
30
+ markdown.split('\n').forEach((text, index) => {
31
+ const opening = FENCE.exec(text)
32
+ if (fence === null) {
33
+ if (opening?.[2] === 'mermaid') {
34
+ fence = opening[1] ?? '```'
35
+ isFlowchart = false
36
+ }
37
+ return
38
+ }
39
+ if (opening && opening[1] === fence && opening[2] === '') {
40
+ fence = null
41
+ return
42
+ }
43
+ if (FLOWCHART.test(text)) isFlowchart = true
44
+ const reason = isFlowchart ? reasonOf(text) : null
45
+ if (reason) problems.push({ line: index + 1, text: text.trim(), reason })
46
+ })
47
+ return problems
48
+ }
@@ -81,10 +81,10 @@ const dependsOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency,
81
81
  /**
82
82
  * A path as the cell writes it, cut to the directory a glob character starts in (`scripts/*.ts` is `scripts`,
83
83
  * `.claude/**` is `.claude`, `*.md` is the root `''`) and without a trailing slash; null for a backticked entry with a
84
- * space in it, which is a command.
84
+ * space in it, which is a command, and for one with `[]`, which is a config key (`watch.sources[].label`) and no glob.
85
85
  */
86
86
  const pathOf = (entry: string): string | null => {
87
- if (/\s/.test(entry)) return null
87
+ if (/\s/.test(entry) || entry.includes('[]')) return null
88
88
  const glob = entry.search(GLOB_CHARACTER)
89
89
  const literal = glob === -1 ? entry : entry.slice(0, Math.max(entry.lastIndexOf('/', glob), 0))
90
90
  const path = literal.replace(/^\.\//, '').replace(/\/+$/, '')