jackherd 1.11.2 → 1.12.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 (94) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +69 -51
  3. package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +15 -5
  6. package/dist/plan-format.d.ts +11 -6
  7. package/dist/plan-format.js +8 -2
  8. package/docs/follow-dark.svg +2 -2
  9. package/docs/follow.svg +2 -2
  10. package/docs/workflow-dark.svg +56 -64
  11. package/docs/workflow.svg +56 -64
  12. package/herdr-plugin/herdr-plugin.toml +1 -1
  13. package/install.sh +1 -1
  14. package/kit/workflow/skills/implement/SKILL.md +1 -1
  15. package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
  16. package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
  17. package/kit/workflow/skills/orchestrate/references/events.md +8 -1
  18. package/kit/workflow/skills/plan/SKILL.md +2 -0
  19. package/package.json +2 -2
  20. package/schemas/jackherd.schema.json +18 -1
  21. package/src/config/link-kinds.ts +1 -1
  22. package/src/config/schema.ts +5 -2
  23. package/src/config/tones.ts +9 -2
  24. package/src/context/fill.ts +4 -10
  25. package/src/context/session-log.ts +108 -2
  26. package/src/plan-format/lanes.ts +10 -1
  27. package/src/plan-format/phases.ts +11 -3
  28. package/src/plan-format/progress.ts +4 -2
  29. package/src/plan-format/waiting.ts +12 -0
  30. package/src/plans/active-tasks.ts +31 -8
  31. package/src/plans/appointment-rows.ts +42 -53
  32. package/src/plans/board-rows.ts +14 -0
  33. package/src/plans/calendar-days.ts +29 -0
  34. package/src/plans/click-hint.ts +94 -0
  35. package/src/plans/command.ts +8 -9
  36. package/src/plans/current-phase.ts +22 -10
  37. package/src/plans/current-task.ts +18 -3
  38. package/src/plans/frame.ts +18 -26
  39. package/src/plans/git.ts +90 -31
  40. package/src/plans/groomed.ts +13 -7
  41. package/src/plans/lane-reason.ts +61 -0
  42. package/src/plans/lane-summary.ts +75 -24
  43. package/src/plans/layout-phase.ts +123 -76
  44. package/src/plans/layout-summary.ts +133 -83
  45. package/src/plans/layout.ts +451 -345
  46. package/src/plans/legend.ts +29 -0
  47. package/src/plans/link-chips.ts +26 -8
  48. package/src/plans/live.ts +238 -84
  49. package/src/plans/next-rows.ts +5 -37
  50. package/src/plans/phase-source.ts +8 -3
  51. package/src/plans/plan-chips.ts +6 -3
  52. package/src/plans/plan-heads.ts +1 -1
  53. package/src/plans/planned.ts +4 -1
  54. package/src/plans/process-slots.ts +36 -0
  55. package/src/plans/pull-requests.ts +9 -1
  56. package/src/plans/read-cache.ts +18 -3
  57. package/src/plans/refresh-pace.ts +13 -0
  58. package/src/plans/row-focus.ts +86 -15
  59. package/src/plans/selection.ts +124 -89
  60. package/src/plans/sent-files.ts +1 -1
  61. package/src/plans/settings.ts +5 -2
  62. package/src/plans/shimmer.ts +124 -0
  63. package/src/plans/signals-read.ts +38 -10
  64. package/src/plans/slice-gate.ts +51 -0
  65. package/src/plans/snapshot.ts +185 -84
  66. package/src/plans/source-asked.ts +1 -1
  67. package/src/plans/source-rows.ts +60 -18
  68. package/src/plans/sprint-html.ts +1 -1
  69. package/src/plans/status-pace.ts +45 -0
  70. package/src/plans/subagent-tasks.ts +1 -1
  71. package/src/plans/subagents-read.ts +61 -24
  72. package/src/plans/subagents.ts +2 -2
  73. package/src/plans/symbols.ts +4 -1
  74. package/src/plans/task-brief.ts +143 -0
  75. package/src/plans/texts.ts +181 -39
  76. package/src/plans/view.ts +198 -32
  77. package/src/plans/waiting-click.ts +54 -110
  78. package/src/plans/watch-burst.ts +25 -0
  79. package/src/plans/worktree-stamp.ts +35 -0
  80. package/src/plans/your-turn-rows.ts +102 -0
  81. package/src/plans/your-turn.ts +226 -0
  82. package/src/terminal/herdr.ts +25 -5
  83. package/src/terminal/restore.ts +44 -0
  84. package/src/terminal/text.ts +2 -2
  85. package/src/terminal/width.ts +9 -0
  86. package/src/vocabulary/index.ts +6 -5
  87. package/src/watch/command.ts +28 -2
  88. package/src/watch/lane-report.ts +2 -0
  89. package/src/watch/lane-rest.ts +7 -1
  90. package/src/watch/reference-paths.ts +1 -0
  91. package/src/watch/unlinked-artifacts-read.ts +43 -0
  92. package/src/watch/unlinked-artifacts.ts +76 -0
  93. package/src/watch/watch-events.ts +12 -0
  94. package/src/watch/watcher-lock.ts +47 -4
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.11.2"
3
+ version = "1.12.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.11.2}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.12.1}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -27,7 +27,7 @@ When the user asks for more than one phase:
27
27
 
28
28
  1. **Read the computed waves:** run `jh check --parallel <plan>`. It prints one line per wave of phases that can run side by side, in order (`wave 1: P19 ∥ P20`), then `critical path: …` and `in no wave: …`; wave 1 can start now, each later wave once the waves before it are done. A phase is ready once every `{{kit.heading.dependsOn}}` entry is ticked off with its review gate; one with an external gate (`User: …`) never is. Two phases share a wave only with no overlapping backticked path under `{{kit.heading.writes}}` (a `like N` entry adds that phase's set), no shared `{{kit.heading.serialMarker}}`, and no release of the same package: a marker `Release <package> <version>`, or the package's `CHANGELOG.md` or `package.json` under `{{kit.heading.writes}}`. `no dependency table`: the phases run one after the other. `no phase is ready`: every open phase waits, is done, or is in no wave.
29
29
  2. **Batch:** the requested phases in wave 1. A requested phase in a later wave waits, naming the phase it collides with. One in no wave is not ready for one of these reasons: it has an external gate (ask whether the gate holds), its `like N` entry names an unknown phase or runs in a cycle, its `{{kit.heading.writes}}` cell has no backticked path, or it depends on such a phase; say which. The command checks paths, markers and releases only: two phases that collide in content stay your judgement, so serialise them yourself.
30
- 3. **Dispatch the batch in one message**, one `Agent(subagent_type: 'implement', model: <from the model table>, description: …, prompt: …)` per phase, in the same checkout: the disjoint `{{kit.heading.writes}}` sets are the isolation. The `description` starts with the short name of the task the subagent works on, the first open one of its phase, then the phase: `<Name> · P<n>`; the herd puts its `▶` on that task. Without a short name, the phase alone. They may run in the background; a hand-over (Step 7) waits until all are back. Each prompt names its phase (`Phase P<n>` and the plan's path, as in Step 0) and its `{{kit.heading.writes}}` set as the **only** paths it may edit, besides ticking its own phase's task lines in the plan, and states that it ticks the task checkboxes of its own phase as it finishes each one (Plan Hygiene), and leaves the review gate, commits and `/pre-commit` to you — it reports changed files, test results and the gate items it ran.
30
+ 3. **Dispatch the batch in one message**, one `Agent(subagent_type: 'implement', model: <from the model table>, description: …, prompt: …)` per phase, in the same checkout: the disjoint `{{kit.heading.writes}}` sets are the isolation. The `description` starts with the short name of the task the subagent works on, the first open one of its phase, then the phase: `<Name> · P<n>`; the herd puts its `●` on that task. Without a short name, the phase alone. They may run in the background; a hand-over (Step 7) waits until all are back. Each prompt names its phase (`Phase P<n>` and the plan's path, as in Step 0) and its `{{kit.heading.writes}}` set as the **only** paths it may edit, besides ticking its own phase's task lines in the plan, and states that it ticks the task checkboxes of its own phase as it finishes each one (Plan Hygiene), and leaves the review gate, commits and `/pre-commit` to you — it reports changed files, test results and the gate items it ran.
31
31
  4. **When the batch returns:** check each report against its set (`git diff --stat`; an edit outside the set is a finding; a ticked line outside its own phase is one too), tick what it left open, walk each review gate. Gate items that write outside a phase's set (documentation, a changelog entry, plan synchronisation) are yours, at the wave's end. Then recompute the ready set and dispatch the next batch.
32
32
  5. **Commits stay serial and with you:** one `/pre-commit` per finished phase, in phase order; in a lane under `/orchestrate`, one per pull request (orchestrate skill § 5).
33
33
 
@@ -302,7 +302,7 @@ counts `groomed <n>d ago` from it and turns it amber at eight days.
302
302
  report handed over for acceptance) goes into the board in the same turn you send it, in the section
303
303
  `## Wartet auf dich` (German plans) or `## Waiting for you` (English), one row `Since | Item | Origin | Status`
304
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
305
- message in this chat does not. Close a row in its status cell in the turn the user's answer arrives (also a
305
+ message in this chat does not. When a lane waits on the answer, add its name as in the lanes table (a phase may follow) to the optional column `Unblocks` (`Gibt frei`) before `Status`, comma-separated, in the turn the lane starts waiting; remove the name once the lane goes on without the answer. The herd lists those rows first, with `unblocks …` under them. Close a row in its status cell in the turn the user's answer arrives (also a
306
306
  "no"), or when the thing is withdrawn; an empty status counts as open. A file sent with `SendUserFile` and an
307
307
  Artifact you publish need no row (the kit hook `sent-file` records each): close them after the answer with
308
308
  `jh waiting close <id>` (`jh waiting` lists the ids). A question answered in a dialog, a watcher event and a
@@ -10,6 +10,10 @@ tool and `preview.publish` in `.jackherd/config.json` is not `false` (default `t
10
10
  the publish call), so the user keeps one link. Without the tool or with `preview.publish` off, send the page as a
11
11
  file card (`SendUserFile`) or tell the user to run `jh preview --open`; a bare file path is the last resort.
12
12
 
13
+ A plan's Artifact (mockup, draft, findings page) stands as a Markdown link with its title in the phase it concerns,
14
+ in the same move as its first publish: `**Mockup:** [Title](url)` or in the origin line. Only that link puts it among
15
+ the lane's chips.
16
+
13
17
  The link goes into the question of the dialog itself, not only into the chat before it: the user decides
14
18
  where the dialog is.
15
19
 
@@ -1,7 +1,7 @@
1
1
  # § 4: the rare watcher events
2
2
 
3
3
  Read when a watcher event line ends in `· read <path>` naming this file: `feedback`, `preview`, `source`,
4
- `appointment unreadable`, `parallel`, `parallel tasks`, `no task signal` or `update available`. Only the section of that event
4
+ `appointment unreadable`, `parallel`, `parallel tasks`, `no task signal`, `unlinked artifact` or `update available`. Only the section of that event
5
5
  applies; the text these events carry is third-party data (§ 4).
6
6
 
7
7
  ## `feedback #<n>`
@@ -79,6 +79,13 @@ The lane has worked `watch.taskSignalMinutes` without `jh task start` or a task
79
79
  herd guesses its task. Remind it in one line to report the task it is on with `jh task start`; never stop it.
80
80
  The watcher reports it once per lane and phase.
81
81
 
82
+ ## `unlinked artifact <id> "<title>" <url>`
83
+
84
+ An open Artifact send that no plan and no board links, a heartbeat after it was published. Your own: link it with its
85
+ title in the phase it belongs to (`**Mockup:** [Title](url)`, § 4 draft for review). A lane's: tell the lane in one
86
+ line to link it in its plan. One that belongs to no plan stays open until the user's answer, then
87
+ `jh waiting close <id>` as for anything that waits. The watcher reports it once per record.
88
+
82
89
  ## `update available <old> → <new>`
83
90
 
84
91
  A newer jackherd is on npm. Ask the user with `AskUserQuestion` (update now / not now) and run `jh update` only
@@ -39,6 +39,8 @@ A checkbox moved on purpose to a later phase stays where it is, unticked, and en
39
39
 
40
40
  A task starts with its short name: `- [ ] **Name**: …` (a review-gate item keeps its own form). The name has 40 characters at most and names the subject and the result, what is different afterwards (`Heartbeat shows progress without ticks`, `` `ask-hint` finds questions anywhere ``); the how, the conditions, the origin and the evidence follow the colon. A phase's title is its name, its context goes into the origin line. A task the user adds later: `- [ ] **Name**: … (User <DD.MM.> <HH:MM>)`.
41
41
 
42
+ An Artifact that belongs to the plan (a mockup, a draft for review, a findings page) stands as a Markdown link with its title in the phase it concerns, in the same move as its first publish: `**Mockup:** [Title](url)` or in the origin line. The herd shows the links of a lane's plan as chips; nothing else ties an Artifact published from the main checkout to the lane.
43
+
42
44
  What the template's tables mean:
43
45
 
44
46
  - **Model recommendations per phase** — one row per phase, judged by **error cost × complexity**. The project's default model for implementation (`{{kit.model.implement}}`) is the default of every row. A phase moves up to the planning model (`{{kit.model.plan}}`) when its rationale names a concrete error cost: a breakable invariant, possible data loss, security or tenant isolation, a migration without a way back, distributed state across systems. "Complex" or "many files" alone keeps the default. Review gates with `/security-audit` always get the strongest model.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.11.2",
3
+ "version": "1.12.1",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -15,7 +15,7 @@
15
15
  "planning",
16
16
  "cli"
17
17
  ],
18
- "description": "Skills, agents and plan tooling for Claude Code projects",
18
+ "description": "Jack herds your coding agents: a coordinator runs your plan in parallel Claude Code lanes in herdr, with a live herd view, a watcher and approval gates",
19
19
  "author": "crossdelta",
20
20
  "license": "MIT",
21
21
  "type": "module",
@@ -198,6 +198,10 @@
198
198
  "type": "number",
199
199
  "minimum": 0
200
200
  },
201
+ "shimmer": {
202
+ "default": true,
203
+ "type": "boolean"
204
+ },
201
205
  "linkKinds": {
202
206
  "default": [
203
207
  {
@@ -213,7 +217,7 @@
213
217
  {
214
218
  "pattern": "^https://claude\\.ai/(code/)?artifact/",
215
219
  "label": "Artifact",
216
- "icon": "✦"
220
+ "icon": "🔗"
217
221
  }
218
222
  ],
219
223
  "type": "array",
@@ -315,6 +319,19 @@
315
319
  "magenta"
316
320
  ]
317
321
  },
322
+ "planned": {
323
+ "default": "teal",
324
+ "type": "string",
325
+ "enum": [
326
+ "amber",
327
+ "teal",
328
+ "grey",
329
+ "red",
330
+ "green",
331
+ "blue",
332
+ "magenta"
333
+ ]
334
+ },
318
335
  "new": {
319
336
  "default": "amber",
320
337
  "type": "string",
@@ -11,7 +11,7 @@ export type LinkKind = {
11
11
  export const ARTIFACT_LINK_KIND: LinkKind = {
12
12
  pattern: '^https://claude\\.ai/(code/)?artifact/',
13
13
  label: 'Artifact',
14
- icon: '✦',
14
+ icon: '🔗',
15
15
  }
16
16
 
17
17
  /** The kinds known without configuration: plain URL patterns, no service is called. */
@@ -223,7 +223,7 @@ const sourceSchema = z
223
223
  ignoreAuthors: z.array(z.string().min(1)).default([]),
224
224
  /** The most new or changed entries one report shows of this source; the rest is one line `+<n> more`. */
225
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. */
226
+ /** Shown before a row of "Your turn" that came from this source (`Error log · Checkout: …`); without it the source's name. */
227
227
  label: z
228
228
  .string()
229
229
  .min(1)
@@ -399,6 +399,7 @@ const DEFAULT_TASK_SIGNAL_MINUTES = 10
399
399
 
400
400
  const tonesSchema = z.strictObject({
401
401
  waiting: toneName.default(DEFAULT_TONES.waiting),
402
+ planned: toneName.default(DEFAULT_TONES.planned),
402
403
  new: toneName.default(DEFAULT_TONES.new),
403
404
  running: toneName.default(DEFAULT_TONES.running),
404
405
  resting: toneName.default(DEFAULT_TONES.resting),
@@ -485,6 +486,8 @@ export const jackherdConfigSchema = z.strictObject({
485
486
  .strictObject({
486
487
  /** Minutes between background fetches of the base branch while `jh herd` runs; 0 turns them off. */
487
488
  fetchIntervalMinutes: z.number().min(0).default(5),
489
+ /** The running task of a folded lane shimmers in `jh herd`; `false` keeps it still. `NO_COLOR` keeps it still too. */
490
+ shimmer: z.boolean().default(true),
488
491
  /** Links in a plan that the sprint page shows as chips; the list replaces the built-in kinds. */
489
492
  linkKinds: z
490
493
  .array(
@@ -496,7 +499,7 @@ export const jackherdConfigSchema = z.strictObject({
496
499
  )
497
500
  .default(DEFAULT_LINK_KINDS.map((kind) => ({ ...kind }))),
498
501
  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. */
502
+ /** The colour of each role in the herd, by name: what waits for you, what is planned for you, what is new, what runs, what rests. */
500
503
  tones: tonesSchema.prefault({}),
501
504
  })
502
505
  .prefault({}),
@@ -13,14 +13,21 @@ export type ToneName = (typeof TONE)[keyof typeof TONE]
13
13
 
14
14
  export const TONE_NAMES = Object.values(TONE)
15
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
16
+ /** What a tone is for in the herd: what waits for you, what is planned for you, what is new, what runs, what rests. */
17
+ export const TONE_ROLE = {
18
+ waiting: 'waiting',
19
+ planned: 'planned',
20
+ new: 'new',
21
+ running: 'running',
22
+ resting: 'resting',
23
+ } as const
18
24
 
19
25
  export type ToneRole = (typeof TONE_ROLE)[keyof typeof TONE_ROLE]
20
26
 
21
27
  /** The tone of each role, as the herd looked before the tones were configurable. */
22
28
  export const DEFAULT_TONES: Record<ToneRole, ToneName> = {
23
29
  [TONE_ROLE.waiting]: TONE.amber,
30
+ [TONE_ROLE.planned]: TONE.teal,
24
31
  [TONE_ROLE.new]: TONE.amber,
25
32
  [TONE_ROLE.running]: TONE.teal,
26
33
  [TONE_ROLE.resting]: TONE.grey,
@@ -1,3 +1,5 @@
1
+ import { keptLogEntry } from './session-log'
2
+
1
3
  /** How the context window of a model is assumed: by model ID, with a default for the models not listed. */
2
4
  export type ContextWindows = { windows: Readonly<Record<string, number>>; defaultWindow: number }
3
5
 
@@ -21,18 +23,10 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
21
23
 
22
24
  const count = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
23
25
 
24
- const parseLine = (line: string): unknown => {
25
- try {
26
- return JSON.parse(line)
27
- } catch {
28
- return null
29
- }
30
- }
31
-
32
26
  /** The answer of a main-chain `assistant` line; null for every other line, a sub-agent's answer and a synthetic one. */
33
27
  const answerOf = (line: string): Answer | null => {
34
- const entry = parseLine(line)
35
- if (!isRecord(entry) || entry.type !== 'assistant' || entry.isSidechain === true) return null
28
+ const entry = keptLogEntry(line)
29
+ if (!entry || entry.type !== 'assistant' || entry.isSidechain === true) return null
36
30
  const { message } = entry
37
31
  if (!isRecord(message) || typeof message.model !== 'string' || message.model === SYNTHETIC_MODEL) return null
38
32
  const { usage } = message
@@ -4,6 +4,9 @@ import { join } from 'node:path'
4
4
  import { CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY } from '../claude/paths'
5
5
  import { isRecord } from '../shared/is-record'
6
6
 
7
+ /** A tail read before: the file as it was (inode, size, modification time), its lines and the byte offset each starts at. */
8
+ type FollowedTail = { inode: number; size: number; modifiedAt: number; lines: string[]; starts: number[] }
9
+
7
10
  /** How much of the end of a log is read: logs grow to hundreds of megabytes, and the last answers are at the end. */
8
11
  export const TAIL_BYTES = 2 * 1024 * 1024
9
12
 
@@ -41,6 +44,90 @@ export const readTailLines = (path: string, bytes: number): string[] => {
41
44
  }
42
45
  }
43
46
 
47
+ /** How many logs keep their tail between reads: the herd follows one per agent, a few dozen at most. */
48
+ const FOLLOWED_LOGS = 32
49
+
50
+ const followed = new Map<string, FollowedTail>()
51
+
52
+ const NEWLINE = 0x0a
53
+
54
+ /**
55
+ * The lines of `buffer`, read from byte `from` of the file, with the offset each starts at. The offsets come from the
56
+ * bytes, not from the decoded text: a cut through a multi-byte character decodes to a replacement of another length.
57
+ */
58
+ const linesFrom = (buffer: Buffer, from: number): { lines: string[]; starts: number[] } => {
59
+ const lines: string[] = []
60
+ const starts: number[] = []
61
+ let lineStart = 0
62
+ for (let end = buffer.indexOf(NEWLINE); end !== -1; end = buffer.indexOf(NEWLINE, lineStart)) {
63
+ lines.push(buffer.toString('utf8', lineStart, end))
64
+ starts.push(from + lineStart)
65
+ lineStart = end + 1
66
+ }
67
+ lines.push(buffer.toString('utf8', lineStart))
68
+ starts.push(from + lineStart)
69
+ return { lines, starts }
70
+ }
71
+
72
+ const readRange = (descriptor: number, from: number, to: number): Buffer => {
73
+ const buffer = Buffer.alloc(to - from)
74
+ readSync(descriptor, buffer, 0, buffer.length, from)
75
+ return buffer
76
+ }
77
+
78
+ /** Whether the file still holds a line break right before `offset`, as it did when its tail was read: it only grew. */
79
+ const grewFrom = (descriptor: number, offset: number): boolean =>
80
+ offset === 0 || readRange(descriptor, offset - 1, offset)[0] === NEWLINE
81
+
82
+ /** The lines a whole read of the tail keeps: those that start after the cut, all of them while the file is short. */
83
+ const withinTail = (tail: { lines: string[]; starts: number[] }, size: number, bytes: number) => {
84
+ const cut = size - bytes
85
+ if (cut <= 0) return tail
86
+ const first = tail.starts.findIndex((start) => start > cut)
87
+ const from = first === -1 ? tail.lines.length : first
88
+ return { lines: tail.lines.slice(from), starts: tail.starts.slice(from) }
89
+ }
90
+
91
+ const readFollowed = (descriptor: number, size: number, bytes: number, kept: FollowedTail | undefined) => {
92
+ const lastStart = kept?.starts.at(-1)
93
+ if (kept && lastStart !== undefined && size > kept.size && grewFrom(descriptor, lastStart)) {
94
+ const added = linesFrom(readRange(descriptor, lastStart, size), lastStart)
95
+ const merged = {
96
+ lines: [...kept.lines.slice(0, -1), ...added.lines],
97
+ starts: [...kept.starts.slice(0, -1), ...added.starts],
98
+ }
99
+ return withinTail(merged, size, bytes)
100
+ }
101
+ const start = Math.max(0, size - bytes)
102
+ return withinTail(linesFrom(readRange(descriptor, start, size), start), size, bytes)
103
+ }
104
+
105
+ /**
106
+ * `readTailLines` for a log that is read again and again while it grows: the lines are kept per path, a log that did
107
+ * not change gives the very same array, and one that grew is read on from its last line only. A log that shrank or
108
+ * was replaced is read anew. The result equals a whole read of the last `bytes`.
109
+ */
110
+ export const followTailLines = (path: string, bytes: number): string[] => {
111
+ const descriptor = openSync(path, 'r')
112
+ try {
113
+ const { size, ino, mtimeMs } = fstatSync(descriptor)
114
+ const key = `${bytes}\0${path}`
115
+ const previous = followed.get(key)
116
+ followed.delete(key)
117
+ const kept = previous?.inode === ino ? previous : undefined
118
+ const unchanged = kept?.size === size && kept.modifiedAt === mtimeMs
119
+ const tail = unchanged
120
+ ? kept
121
+ : { inode: ino, size, modifiedAt: mtimeMs, ...readFollowed(descriptor, size, bytes, kept) }
122
+ followed.set(key, tail)
123
+ const oldest = followed.keys().next().value
124
+ if (followed.size > FOLLOWED_LOGS && oldest !== undefined) followed.delete(oldest)
125
+ return tail.lines
126
+ } finally {
127
+ closeSync(descriptor)
128
+ }
129
+ }
130
+
44
131
  /** The complete lines of the first `bytes` of a file: a cut-off last line is dropped, a file read to its end keeps its last one. */
45
132
  export const readHeadLines = (path: string, bytes: number): string[] => {
46
133
  const descriptor = openSync(path, 'r')
@@ -63,7 +150,7 @@ export const sessionLog = (
63
150
  ): string[] | null => {
64
151
  try {
65
152
  const path = sessionLogPath(sessionId, projectsDirectory)
66
- return path ? readTailLines(path, bytes) : null
153
+ return path ? followTailLines(path, bytes) : null
67
154
  } catch {
68
155
  return null
69
156
  }
@@ -99,7 +186,7 @@ export const agentLog = (
99
186
  if (reported && hasAnswer(reported)) return reported
100
187
  try {
101
188
  const path = cwd ? newestLogOf(cwd, projectsDirectory) : null
102
- return path ? readTailLines(path, TAIL_BYTES) : reported
189
+ return path ? followTailLines(path, TAIL_BYTES) : reported
103
190
  } catch {
104
191
  return reported
105
192
  }
@@ -131,3 +218,22 @@ export const parseLogEntry = (line: string): Record<string, unknown> | null => {
131
218
  return null
132
219
  }
133
220
  }
221
+
222
+ /** How many parsed lines `keptLogEntry` holds: the answers and last entries of the few dozen logs the herd follows. */
223
+ export const KEPT_LOG_ENTRIES = 4096
224
+
225
+ const keptEntries = new Map<string, Record<string, unknown> | null>()
226
+
227
+ /**
228
+ * `parseLogEntry` kept per line text, for logs read again every few seconds: a line read before is not parsed again,
229
+ * also when a re-read hands it back as a new string. The entry is shared, so a caller must not change it. Past
230
+ * `KEPT_LOG_ENTRIES` lines the store starts over.
231
+ */
232
+ export const keptLogEntry = (line: string): Record<string, unknown> | null => {
233
+ const kept = keptEntries.get(line)
234
+ if (kept !== undefined) return kept
235
+ const entry = parseLogEntry(line)
236
+ if (keptEntries.size >= KEPT_LOG_ENTRIES) keptEntries.clear()
237
+ keptEntries.set(line, entry)
238
+ return entry
239
+ }
@@ -70,19 +70,28 @@ const leadingIds = (clause: string, leading: RegExpMatchArray, patterns: PhaseId
70
70
  return spanned.length > 0 ? spanned : [first]
71
71
  }
72
72
 
73
+ /** How many parentheses a clause opens, less the ones it closes. */
74
+ const parenthesisChange = (clause: string): number =>
75
+ (clause.match(/\(/g)?.length ?? 0) - (clause.match(/\)/g)?.length ?? 0)
76
+
73
77
  /**
74
78
  * The phase ids of a phases cell. An id counts where an entry starts: at the start of the cell or of a
75
79
  * sentence, after `;`, after a connector word (`P1 Index und P2 Suche`), or after `,` while a list that began
76
80
  * with an id is running (`P1 (#<number>), P2 (#<number>)`); an entry `P17–P25` names every phase it spans. A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
77
- * P6 startet neu`) is prose.
81
+ * P6 startet neu`) is prose, and so is a clause that starts inside a parenthesis (`(on its branch; P2, P3 merged)`): it
82
+ * names no phase and ends no list. A parenthesis never closed leaves the rest of the cell prose.
78
83
  */
79
84
  const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
80
85
  const parts = cell.split(CLAUSE_SEPARATOR)
81
86
  const ids: string[] = []
82
87
  let listRunning = false
88
+ let depth = 0
83
89
  for (let index = 0; index < parts.length; index += 2) {
84
90
  const separator = index > 0 ? parts[index - 1] : null
85
91
  const clause = parts[index]
92
+ const inParenthesis = depth > 0
93
+ depth = Math.max(depth + parenthesisChange(clause), 0)
94
+ if (inParenthesis) continue
86
95
  const entryStart = separator === null || ENTRY_END.has(separator)
87
96
  if (entryStart) listRunning = false
88
97
  const leading = clause.match(patterns.leading)
@@ -13,10 +13,18 @@ export type DeferredItem = { text: string; target: string }
13
13
  export type MisplacedDeferral = { line: number; target: string; corrected: string }
14
14
 
15
15
  /**
16
- * `touched` is set by the phase source when the worktree's ticks differ from the base branch's. `deferred` holds
17
- * the checkboxes moved to another phase; they are not among `items`, so they count neither as open nor as done.
16
+ * `touched` is set by the phase source when the worktree's ticks differ from the base branch's, `merged` beside it
17
+ * when the base branch already carries a tick of the phase: a run of it was merged. `deferred` holds the checkboxes
18
+ * moved to another phase; they are not among `items`, so they count neither as open nor as done.
18
19
  */
19
- export type PlanPhase = { id: string; title: string; items: ChecklistItem[]; deferred?: DeferredItem[]; touched?: true }
20
+ export type PlanPhase = {
21
+ id: string
22
+ title: string
23
+ items: ChecklistItem[]
24
+ deferred?: DeferredItem[]
25
+ touched?: true
26
+ merged?: true
27
+ }
20
28
 
21
29
  /** A phase heading with its 1-based line, the lower-case id and the title. */
22
30
  export type PhaseHeading = { line: number; id: string; title: string }
@@ -2,8 +2,8 @@ import { type PlanStep, phaseLabel, type SprintLane } from './lanes'
2
2
  import type { ChecklistItem, DeferredItem, PlanPhase } from './phases'
3
3
 
4
4
  /**
5
- * `title` is null when the sprint names a phase the plan does not have; `touched` and `deferred` carry the plan
6
- * phase's own, and the deferred checkboxes are in neither `done` nor `total`.
5
+ * `title` is null when the sprint names a phase the plan does not have; `touched`, `merged` and `deferred` carry the
6
+ * plan phase's own, and the deferred checkboxes are in neither `done` nor `total`.
7
7
  */
8
8
  export type PhaseProgress = {
9
9
  id: string
@@ -13,6 +13,7 @@ export type PhaseProgress = {
13
13
  items: ChecklistItem[]
14
14
  deferred?: DeferredItem[]
15
15
  touched?: true
16
+ merged?: true
16
17
  }
17
18
 
18
19
  export type StepProgress = { step: PlanStep; phases: PhaseProgress[] }
@@ -49,6 +50,7 @@ export const phaseProgress = (phases: readonly PlanPhase[], id: string): PhasePr
49
50
  items,
50
51
  ...(phase?.deferred ? { deferred: phase.deferred } : {}),
51
52
  ...(phase?.touched ? { touched: true as const } : {}),
53
+ ...(phase?.merged ? { merged: true as const } : {}),
52
54
  }
53
55
  }
54
56
 
@@ -14,6 +14,8 @@ export type WaitingItem = {
14
14
  open: boolean
15
15
  /** The board row's origin cell, one line; absent when the table has no such column or the cell is empty. */
16
16
  origin?: string
17
+ /** The lanes (and phases) the board row frees once answered, as its unblocks cell lists them; absent when it names none. */
18
+ unblocks?: string[]
17
19
  /** What the herd shows before the item (the label of the source the row came from); set by the herd, never read from the board. */
18
20
  prefix?: string
19
21
  /** A `file:` address the item links to, for a sent file; absent for a row of the board. */
@@ -26,6 +28,13 @@ export type WaitingItem = {
26
28
 
27
29
  const oneLine = (text: string): string => terminalSafe(text).replace(/\s+/g, ' ').trim()
28
30
 
31
+ /** The comma-separated names of a cell, one line each, without the backticks of a lane name in code. */
32
+ const namesOf = (cell: string): string[] =>
33
+ oneLine(cell)
34
+ .split(',')
35
+ .map((name) => name.replaceAll('`', '').trim())
36
+ .filter(Boolean)
37
+
29
38
  /** An empty status counts as open: a row nobody closed is not dropped in silence. */
30
39
  const isOpen = (status: string, language: PlanLanguage): boolean => {
31
40
  const lower = status.toLowerCase()
@@ -45,9 +54,11 @@ export const readWaitingItems = (board: string, language: PlanLanguage): Waiting
45
54
  if (positions.includes(-1)) return []
46
55
  const [since, item, status] = positions
47
56
  const originPosition = header.indexOf(columns.origin)
57
+ const unblocksPosition = header.indexOf(columns.unblocks)
48
58
  return rows.flatMap((cells) => {
49
59
  const text = oneLine(cells[item] ?? '')
50
60
  const origin = oneLine(cells[originPosition] ?? '')
61
+ const unblocks = namesOf(cells[unblocksPosition] ?? '')
51
62
  return text === ''
52
63
  ? []
53
64
  : [
@@ -56,6 +67,7 @@ export const readWaitingItems = (board: string, language: PlanLanguage): Waiting
56
67
  item: text,
57
68
  open: isOpen(cells[status] ?? '', language),
58
69
  ...(origin === '' ? {} : { origin }),
70
+ ...(unblocks.length === 0 ? {} : { unblocks }),
59
71
  },
60
72
  ]
61
73
  })
@@ -15,22 +15,45 @@ export type UnplannedTask = { text: string; agent: string | null }
15
15
 
16
16
  export type Assignment = { byPhase: ReadonlyMap<string, ActiveTask[]>; unplanned: UnplannedTask[] }
17
17
 
18
- const openTasksOf = (phases: readonly TaskPhase[]): OpenPlanTask[] =>
19
- phases.flatMap(({ id, items }) =>
20
- items.flatMap((item, taskIndex) => (item.done ? [] : [{ phaseId: id, taskIndex, text: item.text }])),
21
- )
18
+ /** Whether any box of the phase is ticked, a gate box included. */
19
+ const hasAnyTick = ({ items }: TaskPhase): boolean => items.some(({ done }) => done)
20
+
21
+ /** The rank a phase's tasks are matched in: the running phase first, then the begun ones, the ones not begun last. */
22
+ const matchRank = (phase: TaskPhase, runningPhaseId: string | null): number => {
23
+ if (phase.id === runningPhaseId) return 0
24
+ return hasAnyTick(phase) ? 1 : 2
25
+ }
26
+
27
+ /**
28
+ * The open tasks a report may name, ranked by `matchRank` so a text that fits boxes in several phases lands in the one
29
+ * the lane runs; the review gate of a phase not begun is left out, since no lane is on the gate of a phase it has not begun.
30
+ */
31
+ const openTasksOf = (phases: readonly TaskPhase[], runningPhaseId: string | null): OpenPlanTask[] =>
32
+ phases
33
+ .map((phase) => ({ phase, rank: matchRank(phase, runningPhaseId) }))
34
+ .sort((first, second) => first.rank - second.rank)
35
+ .flatMap(({ phase }) =>
36
+ phase.items.flatMap((item, taskIndex) =>
37
+ item.done || (item.gate && !hasAnyTick(phase)) ? [] : [{ phaseId: phase.id, taskIndex, text: item.text }],
38
+ ),
39
+ )
22
40
 
23
41
  /**
24
42
  * Lays the reported `signals` over the plan's open tasks: each task an agent named is active, grouped by phase in the
25
- * order of the signals, two agents on one task share it; a signal that names no open task is unplanned. Agent text is
26
- * made terminal safe here, and the agent is named only when more than one agent reports.
43
+ * order of the signals, two agents on one task share it; a signal that names no open task is unplanned. A text that fits
44
+ * boxes in several phases lands in `runningPhaseId` (the open phase ticked last), else a begun one (`openTasksOf`).
45
+ * Agent text is made terminal safe here, and the agent is named only when more than one agent reports.
27
46
  */
28
- export const assignSignals = (phases: readonly TaskPhase[], signals: readonly TaskSignal[]): Assignment => {
47
+ export const assignSignals = (
48
+ phases: readonly TaskPhase[],
49
+ signals: readonly TaskSignal[],
50
+ runningPhaseId: string | null = null,
51
+ ): Assignment => {
29
52
  const named = new Set(signals.map(({ agent }) => terminalSafe(agent))).size > 1
30
53
  const nameOf = (agent: string): string | null => (named ? terminalSafe(agent) : null)
31
54
  const byPhase = new Map<string, ActiveTask[]>()
32
55
  const unplanned: UnplannedTask[] = []
33
- for (const { signal, task } of matchSignals(openTasksOf(phases), signals)) {
56
+ for (const { signal, task } of matchSignals(openTasksOf(phases, runningPhaseId), signals)) {
34
57
  if (task === null) {
35
58
  unplanned.push({ text: terminalSafe(signal.text), agent: nameOf(signal.agent) })
36
59
  continue