jackherd 1.8.0 → 1.9.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 (131) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +41 -60
  4. package/assets/branding/README.md +5 -7
  5. package/assets/branding/jackherd-logo-outline.svg +1 -0
  6. package/assets/branding/outline.test.ts +30 -0
  7. package/assets/icons/bark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide.svg +1 -1
  10. package/assets/icons/ear.svg +1 -1
  11. package/assets/icons/lanes.svg +1 -1
  12. package/assets/icons/plan.svg +1 -1
  13. package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
  14. package/dist/config.d.ts +6 -0
  15. package/dist/config.js +19 -3
  16. package/dist/plan-format.d.ts +22 -3
  17. package/dist/plan-format.js +21 -7
  18. package/herdr-plugin/herdr-plugin.toml +1 -1
  19. package/install.sh +1 -1
  20. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  21. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  22. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  23. package/kit/hooks/hooks/merge-guard/decision.ts +70 -21
  24. package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
  25. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  26. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  27. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  28. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  29. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  30. package/kit/hooks/hooks.json +9 -0
  31. package/kit/hooks/statusline.sh +1 -1
  32. package/kit/workflow/skills/implement/SKILL.md +2 -0
  33. package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
  34. package/package.json +3 -1
  35. package/schemas/jackherd.schema.json +26 -2
  36. package/src/cli.ts +3 -0
  37. package/src/config/read-config.ts +5 -1
  38. package/src/config/schema.ts +22 -2
  39. package/src/context/fill.ts +12 -0
  40. package/src/context/session-log.ts +43 -1
  41. package/src/plan-format/clock.ts +4 -0
  42. package/src/plan-format/dependencies.ts +2 -5
  43. package/src/plan-format/lanes.ts +30 -5
  44. package/src/plan-format/terminal-text.ts +4 -0
  45. package/src/plan-format/waiting.ts +48 -0
  46. package/src/plans/active-tasks.ts +60 -0
  47. package/src/plans/current-phase.ts +17 -7
  48. package/src/plans/current-task.ts +63 -5
  49. package/src/plans/lane-standing.ts +12 -6
  50. package/src/plans/lane-summary.ts +141 -74
  51. package/src/plans/lane-tickets.ts +15 -0
  52. package/src/plans/layout-phase.ts +57 -18
  53. package/src/plans/layout-summary.ts +85 -67
  54. package/src/plans/layout.ts +253 -89
  55. package/src/plans/live.ts +22 -0
  56. package/src/plans/phase-models.ts +1 -1
  57. package/src/plans/phase-ranges.ts +45 -0
  58. package/src/plans/pull-requests.ts +6 -0
  59. package/src/plans/selection.ts +19 -16
  60. package/src/plans/sent-files-read.ts +22 -0
  61. package/src/plans/sent-files.ts +121 -0
  62. package/src/plans/signals-read.ts +54 -0
  63. package/src/plans/snapshot.ts +251 -55
  64. package/src/plans/subagents-read.ts +82 -0
  65. package/src/plans/subagents.ts +132 -0
  66. package/src/plans/texts.ts +16 -11
  67. package/src/plans/unlisted-lanes.ts +10 -5
  68. package/src/plans/view.ts +3 -3
  69. package/src/plans/waiting-click.ts +76 -0
  70. package/src/preview/command.ts +83 -0
  71. package/src/preview/embed.ts +209 -0
  72. package/src/preview/images.ts +89 -0
  73. package/src/preview/open.ts +12 -0
  74. package/src/preview/options.ts +32 -0
  75. package/src/preview/page.ts +89 -0
  76. package/src/preview/render.ts +41 -0
  77. package/src/settled/board-row.ts +1 -2
  78. package/src/shared/is-record.ts +2 -0
  79. package/src/shared/private-records.ts +48 -0
  80. package/src/task-signals/agent.ts +15 -0
  81. package/src/task-signals/claude-read.ts +72 -0
  82. package/src/task-signals/claude-signals.ts +112 -0
  83. package/src/task-signals/command.ts +71 -0
  84. package/src/task-signals/fixtures/session.jsonl +7 -0
  85. package/src/task-signals/matcher.ts +96 -0
  86. package/src/task-signals/record-store.ts +40 -0
  87. package/src/task-signals/record.ts +68 -0
  88. package/src/task-signals/text.ts +13 -0
  89. package/src/task-signals/types.ts +14 -0
  90. package/src/terminal/open.ts +13 -6
  91. package/src/terminal/text.ts +8 -1
  92. package/src/terminal/width.ts +2 -1
  93. package/src/update/command.ts +2 -0
  94. package/src/update/config-shares.ts +30 -0
  95. package/src/update/update.ts +12 -1
  96. package/src/vocabulary/index.ts +33 -1
  97. package/src/waiting/command.ts +35 -0
  98. package/src/watch/appointments.ts +5 -5
  99. package/src/watch/approvals.ts +2 -2
  100. package/src/watch/command.ts +91 -5
  101. package/src/watch/failed-tests.ts +56 -10
  102. package/src/watch/failure-read.ts +18 -6
  103. package/src/watch/failures.ts +29 -5
  104. package/src/watch/feedback.ts +1 -1
  105. package/src/watch/gh-process.ts +9 -3
  106. package/src/watch/json-guards.ts +0 -3
  107. package/src/watch/lane-fill.ts +1 -1
  108. package/src/watch/lane-model.ts +1 -1
  109. package/src/watch/lane-parallel.ts +1 -1
  110. package/src/watch/lane-progress.ts +19 -7
  111. package/src/watch/lane-report.ts +5 -1
  112. package/src/watch/lane-rest.ts +28 -0
  113. package/src/watch/plan-badges.ts +9 -2
  114. package/src/watch/previews.ts +1 -1
  115. package/src/watch/pull-requests.ts +2 -1
  116. package/src/watch/source-path.ts +1 -1
  117. package/src/watch/sources.ts +1 -1
  118. package/src/watch/stream-output.ts +12 -7
  119. package/src/watch/task-parallel-input.ts +118 -0
  120. package/src/watch/task-parallel-read.ts +149 -0
  121. package/src/watch/task-parallel-touched.ts +39 -0
  122. package/src/watch/task-parallel.ts +206 -0
  123. package/src/watch/waiting-line.ts +19 -0
  124. package/src/watch/watch-events.ts +13 -1
  125. package/src/watch/workflow-runs.ts +2 -1
  126. package/assets/icons/bark-dark.svg +0 -1
  127. package/assets/icons/bowl-dark.svg +0 -1
  128. package/assets/icons/decide-dark.svg +0 -1
  129. package/assets/icons/ear-dark.svg +0 -1
  130. package/assets/icons/lanes-dark.svg +0 -1
  131. package/assets/icons/plan-dark.svg +0 -1
@@ -19,6 +19,7 @@ commit, push or merge without the user's approval.
19
19
  **A small fix runs without a lane:** one pull request, no plan, no migration, no write against a shared
20
20
  environment. An `implement` subagent builds it; you take its git steps on a short branch (§ 0), under the
21
21
  same approval (§ 5), and record it in one line of the board's `{{kit.heading.history}}` instead of a lane row and a plan.
22
+ A small fix that builds a phase of a plan gets a row in the board's `{{kit.heading.lanes}}` table (name, plan link, phase), so the herd shows it; without a phase it stays a history line.
22
23
 
23
24
  > Lanes run `/implement`, not this skill. Never paste these rules into a lane prompt.
24
25
 
@@ -88,6 +89,9 @@ Always contains:
88
89
  - what the lane must not do: push a database migration and any write against a shared environment
89
90
  (stage, production) without the user's approval, `git reset` or `git stash` on shared refs
90
91
  - "ask before every commit (`/pre-commit`); no push, PR or merge without approval"
92
+ - "before you start a task, mark it in progress in your own task list; without one, run `jh task start
93
+ "<task text>"`, and `jh task done` when it is finished": the herd shows the task you work on from that
94
+ signal
91
95
  - "a new phase starts with its skeleton in the plan (heading, origin, tasks, review gate) before any
92
96
  research, so the herd never shows it as not in the plan"
93
97
  - "the report at the end of each step goes to `.claude/handover/<lane>-report.md` in the worktree,
@@ -410,13 +414,14 @@ On wake-up:
410
414
  | `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 |
411
415
  | `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 |
412
416
  | `appointment unreadable <when> · <action>` | An open appointment row whose time cannot be read, or a table without a column the watcher needs (`missing columns: …`). Correct the row or the header in the board; until then the appointment never falls due |
413
- | `heartbeat` | One line of status to the user, also when nothing changed |
417
+ | `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 |
414
418
  | `gh unavailable` | Tell the user once; herdr watching continues |
415
419
  | `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 |
416
420
  | `lane <name> alert` | § 8: send the lane once the request for its hand-over at the end of its current phase |
417
421
  | `approval <name> <id> <tool> · <call>` | A call of the lane waits for the user's approval (§ 4, Approvals); the duty line names the question's mark and labels |
418
422
  | `model <name> <id> · board <column>` | The lane's last answer came from a model its board row's model column does not name (every model the column names fits). Switch it while it is idle (§ 2 step 6). A column that is wrong goes to the user instead. The watcher reports a deviation once and again only when the model changes |
419
423
  | `parallel <name> P<a> ∥ P<b>` | The lane's plan has ready phases whose `{{kit.heading.writes}}` sets do not overlap and that share no `{{kit.heading.serialMarker}}`, read from the plan's `{{kit.heading.dependencies}}` table and its ticks. Propose them to the user in one line, as parallel lanes (§ 2) or as parallel subagents in one lane (`/implement` Step 0b); nothing starts on the event alone. Before proposing, check what the table cannot see: two phases that collide in content stay your judgement. The watcher reports a set once and again only when the set changes |
424
+ | `parallel tasks <name>: "<a>" ∥ "<b>" · ~<n>k tokens for <m> extra agents` | Open tasks that name paths overlapping neither each other nor what the running agents wrote lately or report (your own subagents in the lane's worktree count as running), at a cost under `watch.parallelTasks.maxTokens`; one free task beside a running agent counts. Once per changed situation, at a heartbeat. Nothing starts on the line. Put it to the user at once, never into an open dialog (right after it), the user's note read first: at most three plain lines (which tasks, why they do not collide, the cost: model and token baseline of a new agent, and what they wait for otherwise; no invented time saving), then one `AskUserQuestion` of at most 15 words, "start in parallel" (recommended only under the threshold; `Parallel starten` / `Nacheinander lassen` for a German user) or "keep in sequence". Until the answer it is a `?` row in the user's answers section (**Anything that waits on the user's answer**). No second proposal for the same situation after "keep in sequence". Two tasks that collide in content stay your judgement |
420
425
  | `coordinator warn` / `coordinator alert` | § 8, for yourself |
421
426
  | `lane added: <name>` | Nothing: the lane is watched. Check that it has its row (§ 2 step 5); a name `<lane>-<purpose>` without a worktree is a helper agent of that lane (§ 1) and needs no row. One the lane did not announce goes to the user |
422
427
  | `lane removed: <name>` | Nothing after you closed it (§ 7). Otherwise its agent exited or its worktree is gone: read why, then tell the user |
@@ -453,6 +458,20 @@ watcher reports an open row once when it falls due (`due`, below); an empty stat
453
458
  and a reworded or moved row is a new appointment and reports again. Close a reported row through its
454
459
  status cell.
455
460
 
461
+ **Anything that waits on the user's answer** (a preview or a file sent to them, a question put to them, a
462
+ report handed over for acceptance) goes into the board at once, in the section `## Wartet auf dich` (German
463
+ plans) or `## Waiting for you` (English), one row `Since | Item | Origin | Status` (`Seit | Punkt | Herkunft |
464
+ Stand`) with the time as in **An agreed time**. The section survives a hand-over, which a message in this chat
465
+ does not. Enter the row in the same turn you send the thing, not when you notice it was missed. The herd shows
466
+ every open row on top as `for you`, and the watcher repeats them in each heartbeat and status until the
467
+ row is closed. Close a row in its status cell in the turn the user's answer arrives (also when the answer is "no"),
468
+ or when the thing is withdrawn; an empty status counts as open. A file sent with `SendUserFile` and an Artifact you publish need no row of your own: the kit hook
469
+ `sent-file` records each in `.jackherd/sent/`, and the herd, the heartbeat and the status line list the open
470
+ ones next to the board's rows (by file name, without an id). The herd also shows them as 📎 badges on the lane
471
+ they belong to. Close them after the answer with `jh waiting close <id>` (or `--all`); `jh waiting` lists
472
+ what is open, with the ids. A question that is answered in a dialog needs no row, since the dialog is the wait. A watcher event or a lane's report is no row either, unless the answer to it is
473
+ the user's.
474
+
456
475
  **A new idea during a release** (the user's or your own, while a plan's phases run toward one
457
476
  release) is a line in the plan's `## Follow-ups` section, with its origin, and one line to the user:
458
477
  no dialog, no new phase. It enters the release at once only when the release would otherwise be
@@ -521,15 +540,21 @@ Question: Retry the failed check, or leave it?
521
540
  ```
522
541
 
523
542
  **A draft for review.** A lane's draft that the user reviews (a README, a page, a plan) goes in
524
- rounds. Show it where the user can read it: rendered, when this session can publish a page,
525
- otherwise the file path. Ask through `AskUserQuestion` with `multiSelect`, one option per remark,
526
- send the chosen remarks to the lane, and show the same place again once the lane has changed it.
527
- The push waits for the approval of the last round.
543
+ rounds. Show it where the user can read it. When this session has the `Artifact` tool and `preview.publish`
544
+ in `.jackherd/config.json` is not `false` (default `true`), render the draft with `jh preview <file>`
545
+ (GitHub and npm side by side, external images embedded) and publish that page as a private Artifact; every
546
+ later round updates the same URL (`url` on the publish call), so the user keeps one link. Without the tool
547
+ (another provider, Codex) or with `preview.publish` off, send the page as a file card (`SendUserFile`) or
548
+ tell the user to run `jh preview --open`; a bare file path is the last resort. Ask through
549
+ `AskUserQuestion` with `multiSelect`, one option per remark, send the chosen remarks to the lane, and
550
+ show the same place again once the lane has changed it. The push waits for the approval of the last round.
528
551
 
529
552
  **Approvals.** In a lane, a call that would open a permission dialog (an ask rule) is held by the
530
553
  approval gate hook (a project install ships it; a global install has none, and the dialog stays in
531
554
  the lane). The hook records the call, the watcher reports `approval <lane> <id> <tool> · <call>`, and
532
- it waits up to `guard.approvals.waitMinutes` (default 8) for the user's answer. Ask at once, one
555
+ it waits up to `guard.approvals.waitMinutes` (default 8) for the user's answer. A subagent of your own
556
+ session (an `implement` run you started) is held the same way, under the name `subagent-<type>-<id>`
557
+ instead of a lane's; its dialog would otherwise open in your pane and go unnoticed. Ask at once, one
533
558
  question per request, through `AskUserQuestion`:
534
559
 
535
560
  - First read the request, `.jackherd/approvals/<id>.json` in the main checkout, and show its `tool`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -48,6 +48,8 @@
48
48
  "!docs/gen",
49
49
  "assets",
50
50
  "!assets/icons/build.ts",
51
+ "!assets/icons/version.ts",
52
+ "!assets/branding/outline.ts",
51
53
  "recipes",
52
54
  "README.md",
53
55
  "INSTALL.md",
@@ -178,6 +178,17 @@
178
178
  },
179
179
  "additionalProperties": false
180
180
  },
181
+ "preview": {
182
+ "default": {},
183
+ "type": "object",
184
+ "properties": {
185
+ "publish": {
186
+ "default": true,
187
+ "type": "boolean"
188
+ }
189
+ },
190
+ "additionalProperties": false
191
+ },
181
192
  "plans": {
182
193
  "default": {},
183
194
  "type": "object",
@@ -270,13 +281,13 @@
270
281
  "type": "object",
271
282
  "properties": {
272
283
  "warnTokens": {
273
- "default": 200000,
284
+ "default": 350000,
274
285
  "type": "integer",
275
286
  "exclusiveMinimum": 0,
276
287
  "maximum": 9007199254740991
277
288
  },
278
289
  "alertTokens": {
279
- "default": 300000,
290
+ "default": 500000,
280
291
  "type": "integer",
281
292
  "exclusiveMinimum": 0,
282
293
  "maximum": 9007199254740991
@@ -361,6 +372,19 @@
361
372
  },
362
373
  "additionalProperties": false
363
374
  },
375
+ "parallelTasks": {
376
+ "default": {},
377
+ "type": "object",
378
+ "properties": {
379
+ "maxTokens": {
380
+ "default": 100000,
381
+ "type": "integer",
382
+ "exclusiveMinimum": 0,
383
+ "maximum": 9007199254740991
384
+ }
385
+ },
386
+ "additionalProperties": false
387
+ },
364
388
  "previews": {
365
389
  "type": "object",
366
390
  "properties": {
package/src/cli.ts CHANGED
@@ -24,9 +24,12 @@ export const COMMANDS: CommandTable = {
24
24
  [HERD_COMMAND.name]: () => import('./plans/command'),
25
25
  init: () => import('./sync/init-command'),
26
26
  [HERD_COMMAND.alias]: () => import('./plans/command'),
27
+ preview: () => import('./preview/command'),
27
28
  settled: () => import('./settled/command'),
28
29
  sync: () => import('./sync/command'),
30
+ task: () => import('./task-signals/command'),
29
31
  update: () => import('./update/command'),
32
+ waiting: () => import('./waiting/command'),
30
33
  watch: () => import('./watch/command'),
31
34
  }
32
35
 
@@ -13,7 +13,11 @@ export type ResolvedConfig = { config: JackherdConfig; notes: string[] }
13
13
 
14
14
  /** The line for context thresholds read as tokens from a file that still gives them as a share. */
15
15
  const sharesNote = (path: string, notes: readonly string[]): string[] =>
16
- notes.length > 0 ? [`${path} gives context thresholds as a share; \`${CLI_NAME} sync\` writes them as tokens.`] : []
16
+ notes.length > 0
17
+ ? [
18
+ `${path} gives context thresholds as a share; \`${CLI_NAME} sync\` or \`${CLI_NAME} update\` writes them as tokens.`,
19
+ ]
20
+ : []
17
21
 
18
22
  /**
19
23
  * Parses a config file's raw JSON, filling what it leaves out from the repository (see `withDetected`) and reading
@@ -118,6 +118,17 @@ const feedbackSchema = z.strictObject({
118
118
  noFindingTemplates: z.array(z.string().min(1)).default([CLEAN_REVIEW_TEXT]),
119
119
  })
120
120
 
121
+ const DEFAULT_PARALLEL_TASKS_MAX_TOKENS = 100_000
122
+
123
+ const parallelTasksSchema = z.strictObject({
124
+ /**
125
+ * The most the extra agents of a parallel proposal may cost before they do anything: the token baseline of a fresh
126
+ * agent times the agents to add. Above it the watcher does not propose parallel tasks. The baseline is the context
127
+ * the first answer of a lane's session carried.
128
+ */
129
+ maxTokens: z.number().int().positive().default(DEFAULT_PARALLEL_TASKS_MAX_TOKENS),
130
+ })
131
+
121
132
  const checksSchema = z.strictObject({
122
133
  /**
123
134
  * Finds a failed test in a line of a red run's log (`gh run view --log-failed`) after its job, step and timestamp
@@ -228,9 +239,9 @@ const hasUniqueNames = (sources: readonly { name: string }[]): boolean =>
228
239
  const coordinatorSchema = z
229
240
  .strictObject({
230
241
  /** Tokens in the coordinator's context from which the watcher reports `coordinator warn`. */
231
- warnTokens: tokenCount.default(200_000),
242
+ warnTokens: tokenCount.default(350_000),
232
243
  /** Tokens in the coordinator's context from which the watcher reports `coordinator alert`. */
233
- alertTokens: tokenCount.default(300_000),
244
+ alertTokens: tokenCount.default(500_000),
234
245
  })
235
246
  .refine(warnBelowAlert, WARN_BELOW_ALERT)
236
247
 
@@ -410,6 +421,13 @@ export const jackherdConfigSchema = z.strictObject({
410
421
  terminal: z
411
422
  .strictObject({ adapter: z.enum(Object.values(TERMINAL_ADAPTER)).default(TERMINAL_ADAPTER.none) })
412
423
  .prefault({}),
424
+ /** How the coordinator shows a draft for review (`orchestrate` § 4). */
425
+ preview: z
426
+ .strictObject({
427
+ /** Publish the draft as a private Artifact when the session has the tool; `false` sends a file card or `jh preview --open` instead. */
428
+ publish: z.boolean().default(true),
429
+ })
430
+ .prefault({}),
413
431
  plans: z
414
432
  .strictObject({
415
433
  /** Minutes between background fetches of the base branch while `jh herd` runs; 0 turns them off. */
@@ -430,6 +448,7 @@ export const jackherdConfigSchema = z.strictObject({
430
448
  /**
431
449
  * Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
432
450
  * it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
451
+ * `parallelTasks.maxTokens` caps the cost of the extra agents in a proposal of parallel tasks at the heartbeat.
433
452
  * `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
434
453
  * Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
435
454
  * command whose JSON output reports `source` for a new or changed entry.
@@ -439,6 +458,7 @@ export const jackherdConfigSchema = z.strictObject({
439
458
  deploy: deploySchema.optional(),
440
459
  feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
441
460
  checks: checksSchema.prefault({}),
461
+ parallelTasks: parallelTasksSchema.prefault({}),
442
462
  previews: previewsSchema.optional(),
443
463
  sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
444
464
  })
@@ -67,3 +67,15 @@ export const contextFill = (lines: readonly string[], windows: ContextWindows):
67
67
  const window = windowFor(last.model, highest, windows)
68
68
  return { tokens: last.tokens, window, share: Math.min(last.tokens / window, 1), model: last.model }
69
69
  }
70
+
71
+ /**
72
+ * What a fresh agent carries before it has worked: the input of the first main-chain answer in the lines, which holds
73
+ * the system prompt, the project instructions and the first request. Null when no line is a usable answer.
74
+ */
75
+ export const firstAnswerTokens = (lines: readonly string[]): number | null => {
76
+ for (const line of lines) {
77
+ const answer = answerOf(line)
78
+ if (answer) return answer.tokens
79
+ }
80
+ return null
81
+ }
@@ -2,6 +2,7 @@ import { closeSync, existsSync, fstatSync, openSync, readdirSync, readSync, stat
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY } from '../claude/paths'
5
+ import { isRecord } from '../shared/is-record'
5
6
 
6
7
  /** How much of the end of a log is read: logs grow to hundreds of megabytes, and the last answers are at the end. */
7
8
  export const TAIL_BYTES = 2 * 1024 * 1024
@@ -40,6 +41,20 @@ export const readTailLines = (path: string, bytes: number): string[] => {
40
41
  }
41
42
  }
42
43
 
44
+ /** 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
+ export const readHeadLines = (path: string, bytes: number): string[] => {
46
+ const descriptor = openSync(path, 'r')
47
+ try {
48
+ const { size } = fstatSync(descriptor)
49
+ const buffer = Buffer.alloc(Math.min(size, bytes))
50
+ readSync(descriptor, buffer, 0, buffer.length, 0)
51
+ const lines = buffer.toString('utf8').split('\n')
52
+ return size > bytes ? lines.slice(0, -1) : lines
53
+ } finally {
54
+ closeSync(descriptor)
55
+ }
56
+ }
57
+
43
58
  /** The tail of a session's log, read only; null when the session has no log or it cannot be read. */
44
59
  export const sessionLog = (
45
60
  sessionId: string,
@@ -59,7 +74,7 @@ export type AgentSession = { session: string | null; cwd: string | null }
59
74
  const hasAnswer = (lines: readonly string[]): boolean => lines.some((line) => line.includes('"type":"assistant"'))
60
75
 
61
76
  /** Claude Code names a project directory after the working directory, `/` and `.` turned into `-`. */
62
- const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
77
+ export const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
63
78
 
64
79
  const newestLogOf = (cwd: string, projectsDirectory: string): string | null => {
65
80
  const directory = join(projectsDirectory, projectDirectoryOf(cwd))
@@ -89,3 +104,30 @@ export const agentLog = (
89
104
  return reported
90
105
  }
91
106
  }
107
+
108
+ /**
109
+ * The path of the log `agentLog` would read: the reported session's log when it holds an answer, else the newest log
110
+ * of the agent's working directory, else the reported one; null when there is none.
111
+ */
112
+ export const agentLogPath = (
113
+ { session, cwd }: AgentSession,
114
+ projectsDirectory: string = defaultProjectsDirectory(),
115
+ ): string | null => {
116
+ const reported = session ? sessionLogPath(session, projectsDirectory) : null
117
+ try {
118
+ if (reported && hasAnswer(readTailLines(reported, TAIL_BYTES))) return reported
119
+ return (cwd ? newestLogOf(cwd, projectsDirectory) : null) ?? reported
120
+ } catch {
121
+ return reported
122
+ }
123
+ }
124
+
125
+ /** One line of a session log as an object; null for a line that is no JSON or not an object. */
126
+ export const parseLogEntry = (line: string): Record<string, unknown> | null => {
127
+ try {
128
+ const entry: unknown = JSON.parse(line)
129
+ return isRecord(entry) ? entry : null
130
+ } catch {
131
+ return null
132
+ }
133
+ }
@@ -0,0 +1,4 @@
1
+ export const MS_PER_MINUTE = 60_000
2
+
3
+ /** A day, month, hour or minute with a leading zero: `7` → `07`. */
4
+ export const twoDigits = (value: number): string => String(value).padStart(2, '0')
@@ -1,5 +1,5 @@
1
1
  import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
- import { PHASE_ID_PREFIX, phaseLabels } from './lanes'
2
+ import { PHASE_ID_PREFIX, phaseLabels, spannedPhaseIds } from './lanes'
3
3
  import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
4
4
  import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
5
5
  import { isPhaseFinished } from './progress'
@@ -40,7 +40,6 @@ const VERSION = /^v?\d+(?:[.\-+][\w.-]*)?$/i
40
40
  const PACKAGE_SCOPE = /^@[^/]+\//
41
41
  /** The files a release of the package in their directory changes: the changelog and the version in the manifest. */
42
42
  const RELEASE_FILE = /(?:^|\/)(?:CHANGELOG\.md|package\.json)$/
43
- const MAX_RANGE = 50
44
43
 
45
44
  const phaseIdOf = (cell: string): string | null => PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
46
45
 
@@ -63,9 +62,7 @@ const splitOutsideParentheses = (cell: string, separator: string): string[] => {
63
62
  const rangeOf = (entry: string): string[] | null => {
64
63
  const match = PHASE_RANGE.exec(entry)
65
64
  if (!match) return null
66
- const [first, last] = [Number(match[1]), Number(match[2])]
67
- if (first > last || last - first >= MAX_RANGE) return []
68
- return Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
65
+ return spannedPhaseIds(Number(match[1]), Number(match[2]))
69
66
  }
70
67
 
71
68
  const dependsOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, 'after' | 'external'> => {
@@ -32,6 +32,15 @@ export const PHASE_ID_PREFIX = 'P'
32
32
  /** A phase as plans and reports name it: `P4`. */
33
33
  export const phaseLabel = (id: string): string => `${PHASE_ID_PREFIX}${id}`
34
34
 
35
+ /** The widest range a cell may span; a wider one is a typo, not a plan. */
36
+ const MAX_RANGE = 50
37
+
38
+ /** The ids from `first` to `last`, both included; empty when the range runs backwards or spans `MAX_RANGE` or more. */
39
+ export const spannedPhaseIds = (first: number, last: number): string[] =>
40
+ first > last || last - first >= MAX_RANGE
41
+ ? []
42
+ : Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
43
+
35
44
  /** Phases as plans and reports name them, joined by `separator`: `P3 P4`, `P19 ∥ P20`. */
36
45
  export const phaseLabels = (ids: readonly string[], separator: string): string => ids.map(phaseLabel).join(separator)
37
46
 
@@ -39,22 +48,32 @@ const PHASES_COLUMN = 3
39
48
  const CLAUSE_SEPARATOR = /([;,]|\.(?=\s))\s*/
40
49
  const ENTRY_END = new Set([';', '.'])
41
50
 
42
- type PhaseIdPatterns = { leading: RegExp; afterConnector: RegExp; connector: RegExp }
51
+ type PhaseIdPatterns = { leading: RegExp; rangeEnd: RegExp; afterConnector: RegExp; connector: RegExp }
43
52
 
44
53
  const phaseIdPatterns = (language: PlanLanguage): PhaseIdPatterns => {
45
54
  const connectors = PLAN_VOCABULARY[language].phaseConnectors.join('|')
46
55
  const id = `(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+[a-z]?)\\b`
47
56
  return {
48
57
  leading: new RegExp(`^\\s*(?:(?:${connectors})\\s+)*${id}`),
58
+ rangeEnd: new RegExp(`^\\s*[–-]\\s*(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+)\\b`),
49
59
  afterConnector: new RegExp(`^\\s*(?:${connectors})\\s`),
50
60
  connector: new RegExp(`\\b(?:${connectors})\\s+${id}`, 'g'),
51
61
  }
52
62
  }
53
63
 
64
+ /** The ids an entry starting with `leading` names: the one id, or every id of a range `P17–P25` it opens. */
65
+ const leadingIds = (clause: string, leading: RegExpMatchArray, patterns: PhaseIdPatterns): string[] => {
66
+ const first = leading[1]
67
+ const end = clause.slice(leading[0].length).match(patterns.rangeEnd)
68
+ if (!end || !/^\d+$/.test(first)) return [first]
69
+ const spanned = spannedPhaseIds(Number(first), Number(end[1]))
70
+ return spanned.length > 0 ? spanned : [first]
71
+ }
72
+
54
73
  /**
55
74
  * The phase ids of a phases cell. An id counts where an entry starts: at the start of the cell or of a
56
75
  * sentence, after `;`, after a connector word (`P1 Index und P2 Suche`), or after `,` while a list that began
57
- * with an id is running (`P1 (#<number>), P2 (#<number>)`). A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
76
+ * 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,
58
77
  * P6 startet neu`) is prose.
59
78
  */
60
79
  const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
@@ -69,7 +88,7 @@ const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
69
88
  const leading = clause.match(patterns.leading)
70
89
  const startsEntry: boolean = entryStart || listRunning || patterns.afterConnector.test(clause)
71
90
  const counts: boolean = leading !== null && startsEntry
72
- if (leading && counts) ids.push(leading[1])
91
+ if (leading && counts) ids.push(...leadingIds(clause, leading, patterns))
73
92
  ids.push(...[...clause.matchAll(patterns.connector)].map((match) => match[1]))
74
93
  listRunning ||= counts
75
94
  }
@@ -111,7 +130,8 @@ export const withoutRepeats = (lane: SprintLane, step: PlanStep): PlanStep => {
111
130
  /**
112
131
  * The lanes of the sprint document's lanes table (`## Spuren` in `de`) in table order. A row whose
113
132
  * first cell names a lane in backticks belongs to that lane; a `↳` row without a name continues
114
- * the lane above it.
133
+ * the lane above it. A cleaned-up lane stays closed through its `↳` rows, but a later row that names it again
134
+ * without the mark starts the lane anew: the closed rows are history and drop out.
115
135
  */
116
136
  export const parseSprintLanes = (markdown: string, language: PlanLanguage): SprintLane[] => {
117
137
  const lanes = new Map<string, SprintLane>()
@@ -123,7 +143,12 @@ export const parseSprintLanes = (markdown: string, language: PlanLanguage): Spri
123
143
  if (!name) continue
124
144
  const port = Number.parseInt(cells[1] ?? '', 10)
125
145
  const lane = lanes.get(name) ?? { name, port: Number.isNaN(port) ? null : port, steps: [], closed: false }
126
- lane.closed ||= (cells[0] ?? '').includes(laneClosed)
146
+ const marked = (cells[0] ?? '').includes(laneClosed)
147
+ if (lane.closed && !marked && !(cells[0] ?? '').startsWith(CONTINUATION)) {
148
+ lane.closed = false
149
+ lane.steps = []
150
+ }
151
+ lane.closed ||= marked
127
152
  lane.steps.push(withoutRepeats(lane, toStep(cells, phaseId)))
128
153
  lanes.set(name, lane)
129
154
  previous = name
@@ -26,3 +26,7 @@ const UNSAFE_TERMINAL_TEXT = new RegExp(
26
26
  * on the terminal. Apply it where such text enters, before any styling is added.
27
27
  */
28
28
  export const terminalSafe = (text: string): string => text.replace(UNSAFE_TERMINAL_TEXT, '')
29
+
30
+ /** The text cut to `max` characters with an ellipsis as the last one; text that fits stays as it is. */
31
+ export const ellipsize = (text: string, max: number): string =>
32
+ text.length > max ? `${text.slice(0, max - 1)}…` : text
@@ -0,0 +1,48 @@
1
+ import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
2
+ import { firstTableWithHeader, sectionLines } from './markdown'
3
+ import { terminalSafe } from './terminal-text'
4
+
5
+ /** One row of the board's section of what waits on the user: when it began (as written), what it is, whether it is open. */
6
+ export type WaitingItem = {
7
+ since: string
8
+ item: string
9
+ open: boolean
10
+ /** A `file:` address the item links to, for a sent file; absent for a row of the board. */
11
+ url?: string
12
+ /** `file` for a file or an Artifact that was sent; absent for a question of the board. */
13
+ kind?: 'file'
14
+ /** The id of the record that closes it (`jh waiting close`); not shown. */
15
+ id?: string
16
+ }
17
+
18
+ const oneLine = (text: string): string => terminalSafe(text).replace(/\s+/g, ' ').trim()
19
+
20
+ /** An empty status counts as open: a row nobody closed is not dropped in silence. */
21
+ const isOpen = (status: string, language: PlanLanguage): boolean => {
22
+ const lower = status.toLowerCase()
23
+ return lower === '' || WAITING_VOCABULARY[language].open.some((word) => lower.startsWith(word))
24
+ }
25
+
26
+ /**
27
+ * The rows of the board's waiting section in `language` (`## Wartet auf dich`, `## Waiting for you`), its columns
28
+ * found by their header names in any order; the origin column is not read. A table without the since, item or status
29
+ * column, a section without a table and a row without an item give nothing.
30
+ */
31
+ export const readWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] => {
32
+ const { heading, columns, statusAlso } = WAITING_VOCABULARY[language]
33
+ const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
34
+ const statusName = [columns.status, ...statusAlso].find((name) => header.includes(name)) ?? columns.status
35
+ const positions = [columns.since, columns.item, statusName].map((name) => header.indexOf(name))
36
+ if (positions.includes(-1)) return []
37
+ const [since, item, status] = positions
38
+ return rows.flatMap((cells) => {
39
+ const text = oneLine(cells[item] ?? '')
40
+ return text === ''
41
+ ? []
42
+ : [{ since: oneLine(cells[since] ?? ''), item: text, open: isOpen(cells[status] ?? '', language) }]
43
+ })
44
+ }
45
+
46
+ /** The open items, in the order of the board. */
47
+ export const openWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] =>
48
+ readWaitingItems(board, language).filter(({ open }) => open)
@@ -0,0 +1,60 @@
1
+ import { sep } from 'node:path'
2
+ import { terminalSafe } from '../plan-format/terminal-text'
3
+ import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
4
+ import type { TaskSignal } from '../task-signals/types'
5
+ import type { TaskPhase } from './current-task'
6
+
7
+ /**
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
+ * is active in the lane), whether the herd only guesses it from words, and when the agent began it (null for a guess).
10
+ */
11
+ export type ActiveTask = { index: number; agent: string | null; guessed: boolean; startedAt: number | null }
12
+
13
+ /** A task an agent reports that no open task of the plan names. */
14
+ export type UnplannedTask = { text: string; agent: string | null }
15
+
16
+ export type Assignment = { byPhase: ReadonlyMap<string, ActiveTask[]>; unplanned: UnplannedTask[] }
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
+ )
22
+
23
+ /**
24
+ * 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.
27
+ */
28
+ export const assignSignals = (phases: readonly TaskPhase[], signals: readonly TaskSignal[]): Assignment => {
29
+ const named = new Set(signals.map(({ agent }) => terminalSafe(agent))).size > 1
30
+ const nameOf = (agent: string): string | null => (named ? terminalSafe(agent) : null)
31
+ const byPhase = new Map<string, ActiveTask[]>()
32
+ const unplanned: UnplannedTask[] = []
33
+ for (const { signal, task } of matchSignals(openTasksOf(phases), signals)) {
34
+ if (task === null) {
35
+ unplanned.push({ text: terminalSafe(signal.text), agent: nameOf(signal.agent) })
36
+ continue
37
+ }
38
+ const tasks = byPhase.get(task.phaseId) ?? []
39
+ const shared = tasks.find(({ index }) => index === task.taskIndex)
40
+ const agent = nameOf(signal.agent)
41
+ if (shared) shared.agent = shared.agent && agent ? `${shared.agent}, ${agent}` : shared.agent
42
+ else tasks.push({ index: task.taskIndex, agent, guessed: false, startedAt: signal.startedAt })
43
+ byPhase.set(task.phaseId, tasks)
44
+ }
45
+ return { byPhase, unplanned }
46
+ }
47
+
48
+ /**
49
+ * The signals still believed: a `jh task` record counts only while it is younger than the lane's last tick, since a
50
+ * ticked box is the better witness that the agent moved on; Claude's own task list closes itself. Without a tick, all stay.
51
+ */
52
+ export const freshSignals = (signals: readonly TaskSignal[], lastTickAt: number | null): TaskSignal[] =>
53
+ signals.filter(({ source, startedAt }) => source !== 'jh' || lastTickAt === null || startedAt >= lastTickAt)
54
+
55
+ /** The lane a directory belongs to: the first directory below `worktrees` it lies in; null anywhere else. */
56
+ export const laneOfDirectory = (directory: string, worktrees: string): string | null => {
57
+ const prefix = worktrees.endsWith(sep) ? worktrees : `${worktrees}${sep}`
58
+ if (!directory.startsWith(prefix)) return null
59
+ return directory.slice(prefix.length).split(sep)[0] || null
60
+ }
@@ -6,7 +6,7 @@ export type PhaseRef = { planHref: string; phaseId: string }
6
6
  /** Per plan link and phase id, the state of its checkboxes as `1` (ticked) and `0` (open) in plan order. */
7
7
  export type TickSnapshot = Record<string, Record<string, string>>
8
8
 
9
- /** When a phase's ticks or task lines last moved, in milliseconds, by `activityKey`. */
9
+ /** When a phase was last ticked or unticked, in milliseconds, by `activityKey`. */
10
10
  export type ActivityTimes = Readonly<Record<string, number>>
11
11
 
12
12
  /**
@@ -54,20 +54,30 @@ export const movedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRe
54
54
  }),
55
55
  )
56
56
 
57
- /** The phases `next` has in a plan `previous` read already but without them: task lines a lane wrote into a new phase. */
58
- const addedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
57
+ const tickCount = (ticks: string): number => [...ticks].filter((state) => state === '1').length
58
+
59
+ /**
60
+ * The phases whose number of ticks changed between two reads of a plan `previous` read already, a phase new in `next`
61
+ * counted from none: a tick is activity, task lines written into a phase are not, so phases a lane only wrote down
62
+ * never run (17:10). A phase remembered in the format before D67 has no state to move from.
63
+ */
64
+ const tickedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
59
65
  Object.entries(next).flatMap(([planHref, phases]) => {
60
66
  const before = previous[planHref]
61
67
  if (before === undefined) return []
62
- return Object.keys(phases).flatMap((phaseId) => (phaseId in before ? [] : [{ planHref, phaseId }]))
68
+ return Object.entries(phases).flatMap(([phaseId, ticks]) => {
69
+ const old = before[phaseId]
70
+ if (old !== undefined && !old.startsWith(TASK_TICKS)) return []
71
+ return tickCount(old ?? '') === tickCount(ticks) ? [] : [{ planHref, phaseId }]
72
+ })
63
73
  })
64
74
 
65
75
  /** When each plan file was last written, in milliseconds, by plan link; a plan without an entry is bounded by the clock. */
66
76
  export type PlanModified = Readonly<Record<string, number>>
67
77
 
68
78
  /**
69
- * When each phase last moved, after a read at `now` whose ticks are `ticks`: a phase whose ticks or task lines changed
70
- * since `previous`, or that is new in a plan `previous` read already, takes the time its plan file was last written
79
+ * When each phase was last ticked, after a read at `now` whose ticks are `ticks`: a phase whose number of ticks changed
80
+ * since `previous` (`tickedPhases`), a new one in a plan `previous` read already included, takes the time its plan file was last written
71
81
  * (`modified`), bounded by `now`, so a change first seen long after it was made is not taken for a fresh one; the
72
82
  * others keep their time. The first read knows no movement, so without a remembered read no phase has a time.
73
83
  */
@@ -78,7 +88,7 @@ export const nextActivity = (
78
88
  modified: PlanModified = {},
79
89
  ): ActivityTimes => {
80
90
  if (!previous) return {}
81
- const changed = [...movedPhases(previous.ticks, ticks), ...addedPhases(previous.ticks, ticks)]
91
+ const changed = tickedPhases(previous.ticks, ticks)
82
92
  return {
83
93
  ...previous.activity,
84
94
  ...Object.fromEntries(