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
@@ -0,0 +1,68 @@
1
+ import { isRecord } from '../shared/is-record'
2
+ import { cleanAgentName, cleanTaskText, MAX_TASK_TEXT } from './text'
3
+ import type { TaskSignal } from './types'
4
+
5
+ /** What `jh task start` stores in `.jackherd/tasks/<id>.json`: one record per agent, closed by `jh task done`. */
6
+ export type TaskRecord = {
7
+ id: string
8
+ agent: string
9
+ text: string
10
+ startedAt: number
11
+ cwd: string
12
+ closedAt: number | null
13
+ }
14
+
15
+ const ID = /^[0-9a-f]{12}$/
16
+ const ID_BYTES = 6
17
+
18
+ /** The id of a record from random bytes the caller drew, so a test reaches the drawing; 12 hex characters. */
19
+ export const buildTaskId = (randomBytes: Uint8Array): string =>
20
+ [...randomBytes.slice(0, ID_BYTES)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
21
+
22
+ export const isTaskRecord = (value: unknown): value is TaskRecord => {
23
+ if (!isRecord(value)) return false
24
+ const record = value
25
+ return (
26
+ typeof record.id === 'string' &&
27
+ ID.test(record.id) &&
28
+ typeof record.agent === 'string' &&
29
+ typeof record.text === 'string' &&
30
+ record.text.length <= MAX_TASK_TEXT &&
31
+ typeof record.startedAt === 'number' &&
32
+ typeof record.cwd === 'string' &&
33
+ (record.closedAt === null || typeof record.closedAt === 'number')
34
+ )
35
+ }
36
+
37
+ /** A fresh open record; the text and the agent name are cleaned here, whoever wrote them. */
38
+ export const openRecord = (
39
+ fields: { id: string; agent: string; text: string; cwd: string },
40
+ now: number,
41
+ ): TaskRecord => ({
42
+ id: fields.id,
43
+ agent: cleanAgentName(fields.agent),
44
+ text: cleanTaskText(fields.text),
45
+ startedAt: now,
46
+ cwd: fields.cwd,
47
+ closedAt: null,
48
+ })
49
+
50
+ /** Whether `text` names the record's task: empty text names any, else it is the start of the record's text. */
51
+ export const namesTask = (record: TaskRecord, text: string): boolean => {
52
+ const wanted = cleanTaskText(text)
53
+ return wanted === '' || record.text.startsWith(wanted)
54
+ }
55
+
56
+ /** The open records as signals; a record cleaned on write is cleaned again on read, as a hand-written file may differ. */
57
+ export const recordSignals = (records: readonly TaskRecord[]): TaskSignal[] =>
58
+ records
59
+ .filter(({ closedAt }) => closedAt === null)
60
+ .map(({ agent, text, startedAt, cwd }) => ({
61
+ agent: cleanAgentName(agent),
62
+ source: 'jh' as const,
63
+ text: cleanTaskText(text),
64
+ startedAt,
65
+ cwd,
66
+ }))
67
+ .filter(({ text }) => text !== '')
68
+ .sort((left, right) => left.startedAt - right.startedAt)
@@ -0,0 +1,13 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+
3
+ /** The longest task text a signal carries; a plan task is matched on its first 200 characters as well. */
4
+ export const MAX_TASK_TEXT = 200
5
+ const MAX_AGENT_NAME = 100
6
+
7
+ const capped = (text: string, max: number): string => terminalSafe(text.replace(/\s+/g, ' ')).trim().slice(0, max)
8
+
9
+ /** Agent-written task text made safe to store and print: no control characters, one line, at most 200 characters. */
10
+ export const cleanTaskText = (text: string): string => capped(text, MAX_TASK_TEXT)
11
+
12
+ /** The same for the name of an agent. */
13
+ export const cleanAgentName = (name: string): string => capped(name, MAX_AGENT_NAME)
@@ -0,0 +1,14 @@
1
+ /** Who reported a task: Claude Code's task list, Codex's plan tool, or `jh task start`. */
2
+ export type TaskSource = 'claude' | 'codex' | 'jh'
3
+
4
+ /**
5
+ * One task an agent is working on right now. `agent` names the session or subagent that reported it, `startedAt` is
6
+ * epoch milliseconds, `cwd` the directory the agent worked in (its worktree tells the lane).
7
+ */
8
+ export type TaskSignal = {
9
+ agent: string
10
+ source: TaskSource
11
+ text: string
12
+ startedAt: number
13
+ cwd: string
14
+ }
@@ -5,10 +5,16 @@ export type OpenEnvironment = {
5
5
  start: (command: string, args: string[]) => void
6
6
  }
7
7
 
8
- const OPENERS: Partial<Record<NodeJS.Platform, string>> = { darwin: 'open', linux: 'xdg-open' }
8
+ const OPENERS: Partial<Record<NodeJS.Platform, (target: string) => string[]>> = {
9
+ darwin: (target) => ['open', target],
10
+ linux: (target) => ['xdg-open', target],
11
+ /** Not `cmd /c start`: cmd reads `&`, `|`, `%` and `^` in the target as its own syntax; rundll32 takes it as one argument. */
12
+ win32: (target) => ['rundll32', 'url.dll,FileProtocolHandler', target],
13
+ }
9
14
 
10
- /** The command that opens a file or a link in the default application of the platform, null when there is none. */
11
- export const openerFor = (platform: NodeJS.Platform): string | null => OPENERS[platform] ?? null
15
+ /** The command and arguments that open a file or a link in the default application of the platform, null when there is none. */
16
+ export const openCommandFor = (platform: NodeJS.Platform, target: string): string[] | null =>
17
+ OPENERS[platform]?.(target) ?? null
12
18
 
13
19
  /** Linux opens things in a graphical session only; elsewhere the platform's opener is assumed to work. */
14
20
  export const hasDisplay = ({ platform, env }: Pick<OpenEnvironment, 'platform' | 'env'>): boolean =>
@@ -27,11 +33,12 @@ const systemEnvironment = (): OpenEnvironment => ({
27
33
  * opener for the platform, no display, the opener could not be started), null when it was started.
28
34
  */
29
35
  export const openInBrowser = (target: string, environment: OpenEnvironment = systemEnvironment()): string | null => {
30
- const opener = openerFor(environment.platform)
31
- if (!opener) return `no way to open ${target} on ${environment.platform}`
36
+ const command = openCommandFor(environment.platform, target)
37
+ if (!command) return `no way to open ${target} on ${environment.platform}`
32
38
  if (!hasDisplay(environment)) return 'no display'
39
+ const [opener, ...args] = command
33
40
  try {
34
- environment.start(opener, [target])
41
+ environment.start(opener, args)
35
42
  return null
36
43
  } catch (error) {
37
44
  return `${opener} ${target}: ${error instanceof Error ? error.message : String(error)}`
@@ -34,6 +34,13 @@ const FAINT_OFF = `${ESC}[22m`
34
34
  const BAR_WIDTH = 10
35
35
 
36
36
  const style = (code: string) => (text: string) => `${ESC}[${code}m${text}${RESET}`
37
+ const BRAND_TEAL_TRUE_COLOUR = '38;2;3;181;189'
38
+ const BRAND_TEAL_256 = '38;5;37'
39
+
40
+ /** The brand teal `#03B5BD` as true colour where `COLORTERM` says `truecolor` or `24bit`, else 256-colour 37. */
41
+ export const tealCodeOf = (env: Record<string, string | undefined>): string =>
42
+ env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' ? BRAND_TEAL_TRUE_COLOUR : BRAND_TEAL_256
43
+
37
44
  export const paint = {
38
45
  bold: style('1'),
39
46
  dim: style('2'),
@@ -41,7 +48,7 @@ export const paint = {
41
48
  green: style('32'),
42
49
  yellow: style('33'),
43
50
  magenta: style('35'),
44
- teal: style('38;5;37'),
51
+ teal: style(tealCodeOf(process.env)),
45
52
  cyan: style('36'),
46
53
  inverse: style('7'),
47
54
  }
@@ -1,4 +1,5 @@
1
1
  const VARIATION_SELECTOR = 0xfe0f
2
+ const TEXT_SELECTOR = 0xfe0e
2
3
  const ZERO_WIDTH_JOINER = 0x200d
3
4
  const COMBINING_MARKS: readonly [number, number][] = [[0x0300, 0x036f]]
4
5
 
@@ -59,7 +60,7 @@ const within = (ranges: readonly [number, number][], codePoint: number): boolean
59
60
  * variation selector (`⏸`) is one cell on its own; `textWidth` counts it as two when the selector follows.
60
61
  */
61
62
  export const cellWidth = (codePoint: number): number => {
62
- if (codePoint === VARIATION_SELECTOR || codePoint === ZERO_WIDTH_JOINER) return 0
63
+ if (codePoint === VARIATION_SELECTOR || codePoint === TEXT_SELECTOR || codePoint === ZERO_WIDTH_JOINER) return 0
63
64
  if (within(COMBINING_MARKS, codePoint)) return 0
64
65
  return within(WIDE, codePoint) ? 2 : 1
65
66
  }
@@ -14,6 +14,7 @@ import { defaultUpdateCheck, lockedVersion } from '../sync/if-changed'
14
14
  import { locateKit } from '../sync/kit-source'
15
15
  import { readInstalledVersion } from '../version/installed-version'
16
16
  import { CLI_NAME } from '../version/release'
17
+ import { rewriteConfigShares } from './config-shares'
17
18
  import { detectPackageManager, legacyPackageRoot, shellHostedGlobalRoot } from './package-manager'
18
19
  import { runUpdate, UPDATE_EXIT, type UpdateAdapters } from './update'
19
20
 
@@ -52,6 +53,7 @@ const adaptersFor = async (): Promise<UpdateAdapters> => {
52
53
  home,
53
54
  projectRoot: findProjectRoot(process.cwd(), home, existsSync),
54
55
  hasProjectKit: (root) => hasProjectInstall(root, existsSync),
56
+ rewriteConfigShares,
55
57
  log: (line) => console.log(line),
56
58
  }
57
59
  }
@@ -0,0 +1,30 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { contextSharesTextInTokens } from '../config/context-shares'
4
+ import { PROJECT_CONFIG_PATH } from '../config/location'
5
+
6
+ type Converted = { text: string; notes: string[] }
7
+
8
+ /** The file's text with its shares in tokens; null when it is missing or not JSON, which reading the config names. */
9
+ const converted = (path: string): Converted | null => {
10
+ if (!existsSync(path)) return null
11
+ try {
12
+ return contextSharesTextInTokens(readFileSync(path, 'utf8'))
13
+ } catch {
14
+ return null
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Writes the context thresholds of `root`'s `.jackherd/config.json` as tokens when the file still gives them as a
20
+ * share, and returns a line for each block that changed. A project without an install of its own has no sync that
21
+ * would do it. A missing or unreadable file is left alone: reading the config names that error. A failed write
22
+ * throws, so `jh update` reports it.
23
+ */
24
+ export const rewriteConfigShares = (root: string): string[] => {
25
+ const path = join(root, PROJECT_CONFIG_PATH)
26
+ const result = converted(path)
27
+ if (result === null || result.notes.length === 0) return []
28
+ writeFileSync(path, result.text)
29
+ return result.notes.map((note) => `${PROJECT_CONFIG_PATH}: ${note}`)
30
+ }
@@ -28,6 +28,8 @@ export type UpdateAdapters = {
28
28
  * The lock's existence counts, not its validity: a broken lock is synced, and the sync names the error.
29
29
  */
30
30
  hasProjectKit: (root: string) => boolean
31
+ /** Writes the context thresholds in the project's config as tokens when they are still a share; returns a line for each change. */
32
+ rewriteConfigShares: (root: string) => string[]
31
33
  log: (line: string) => void
32
34
  }
33
35
 
@@ -46,6 +48,11 @@ const notInstalledLine = (projectRoot: string): string =>
46
48
  const installedProject = (adapters: UpdateAdapters): string | null =>
47
49
  adapters.projectRoot && adapters.hasProjectKit(adapters.projectRoot) ? adapters.projectRoot : null
48
50
 
51
+ /** Rewrites the context thresholds of a project without an install, which no sync does, and says what changed. */
52
+ const logConfigShares = (adapters: UpdateAdapters, root: string): void => {
53
+ for (const line of adapters.rewriteConfigShares(root)) adapters.log(line)
54
+ }
55
+
49
56
  /** The syncs that bring `~/.claude` and the project to the new files, in the order they run. */
50
57
  const syncSteps = async (
51
58
  adapters: UpdateAdapters,
@@ -107,6 +114,7 @@ const updateToLatest = async (adapters: UpdateAdapters, manager: PackageManager)
107
114
  const jhWasOnPath = adapters.jhOnPath()
108
115
  if (jhWasOnPath && !isNewerVersion(latest, installedVersion)) {
109
116
  log(`${CLI_NAME} ${installedVersion} is the latest release.`)
117
+ if (adapters.projectRoot && !installedProject(adapters)) logConfigShares(adapters, adapters.projectRoot)
110
118
  return UPDATE_EXIT.ok
111
119
  }
112
120
  const installed = await installAndVerify(adapters, manager, latest)
@@ -115,7 +123,10 @@ const updateToLatest = async (adapters: UpdateAdapters, manager: PackageManager)
115
123
  const synced = await syncAfterInstall(adapters, project)
116
124
  if (synced !== UPDATE_EXIT.ok) return synced
117
125
  log(doneLine(adapters, manager, latest, jhWasOnPath))
118
- if (adapters.projectRoot && !project) log(notInstalledLine(adapters.projectRoot))
126
+ if (adapters.projectRoot && !project) {
127
+ logConfigShares(adapters, adapters.projectRoot)
128
+ log(notInstalledLine(adapters.projectRoot))
129
+ }
119
130
  return UPDATE_EXIT.ok
120
131
  }
121
132
 
@@ -66,7 +66,7 @@ export const APPROVAL_TEXTS: Record<PlanLanguage, { allow: string; deny: string;
66
66
 
67
67
  /**
68
68
  * The board section of agreed times, which the watcher reads: its heading, the header names of its columns (when,
69
- * action, origin, status; found by name, in any order) and the lower-case words a status cell of a still open
69
+ * action, origin, status, found by name in any order; the status column may also carry a name of `statusAlso`) and the lower-case words a status cell of a still open
70
70
  * appointment starts with.
71
71
  */
72
72
  export const APPOINTMENT_VOCABULARY: Record<
@@ -74,17 +74,49 @@ export const APPOINTMENT_VOCABULARY: Record<
74
74
  {
75
75
  heading: string
76
76
  columns: { when: string; action: string; origin: string; status: string }
77
+ /** Further header names that count as the status column. */
78
+ statusAlso: readonly string[]
77
79
  open: readonly string[]
78
80
  }
79
81
  > = {
80
82
  [PLAN_LANGUAGE.de]: {
81
83
  heading: 'Termine',
82
84
  columns: { when: 'Wann', action: 'Aktion', origin: 'Herkunft', status: 'Stand' },
85
+ statusAlso: ['Status'],
83
86
  open: ['offen'],
84
87
  },
85
88
  [PLAN_LANGUAGE.en]: {
86
89
  heading: 'Appointments',
87
90
  columns: { when: 'When', action: 'Action', origin: 'Origin', status: 'Status' },
91
+ statusAlso: [],
92
+ open: ['open'],
93
+ },
94
+ }
95
+
96
+ /**
97
+ * The board section of what waits on the user's answer (a sent preview, an open question, a report to accept): its
98
+ * heading, the header names of its columns (since, item, origin, status, found by name in any order; the status
99
+ * column may also carry a name of `statusAlso`) and the lower-case words a status cell of a still open item starts with.
100
+ */
101
+ export const WAITING_VOCABULARY: Record<
102
+ PlanLanguage,
103
+ {
104
+ heading: string
105
+ columns: { since: string; item: string; origin: string; status: string }
106
+ statusAlso: readonly string[]
107
+ open: readonly string[]
108
+ }
109
+ > = {
110
+ [PLAN_LANGUAGE.de]: {
111
+ heading: 'Wartet auf dich',
112
+ columns: { since: 'Seit', item: 'Punkt', origin: 'Herkunft', status: 'Stand' },
113
+ statusAlso: ['Status'],
114
+ open: ['offen'],
115
+ },
116
+ [PLAN_LANGUAGE.en]: {
117
+ heading: 'Waiting for you',
118
+ columns: { since: 'Since', item: 'Item', origin: 'Origin', status: 'Status' },
119
+ statusAlso: [],
88
120
  open: ['open'],
89
121
  },
90
122
  }
@@ -0,0 +1,35 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import type { WaitingItem } from '../plan-format/waiting'
3
+ import { mainCheckout } from '../plans/git'
4
+ import { sentWaitingItems } from '../plans/sent-files'
5
+ import { closeAllSentRecords, closeSentRecords, readSentRecords } from '../plans/sent-files-read'
6
+ import { CLI_NAME } from '../version/release'
7
+
8
+ const USAGE = `usage: ${CLI_NAME} waiting [close <id> ... | close --all]`
9
+
10
+ /** The lines `jh waiting` prints: id, time and text of each item, made safe for the terminal; a note when none waits. */
11
+ export const waitingListing = (items: readonly WaitingItem[]): string[] =>
12
+ items.length === 0
13
+ ? ['nothing waits on you']
14
+ : items.map(({ since, item, id }) => terminalSafe(`${id} · ${since} · ${item}`))
15
+
16
+ /**
17
+ * `jh waiting` lists the files sent to the user that no one closed (their id, time and caption);
18
+ * `jh waiting close <id> ...` or `close --all` closes them once the user has answered. Exit 0, 1 on a wrong command line.
19
+ */
20
+ export const run = async (args: string[]): Promise<number> => {
21
+ const root = mainCheckout(process.cwd())
22
+ if (args.length === 0) {
23
+ for (const line of waitingListing(sentWaitingItems(readSentRecords(root), Date.now()))) console.log(line)
24
+ return 0
25
+ }
26
+ const [command, ...rest] = args
27
+ if (command !== 'close' || rest.length === 0) {
28
+ console.error(USAGE)
29
+ return 1
30
+ }
31
+ const now = Date.now()
32
+ const closed = rest.includes('--all') ? closeAllSentRecords(root, now) : closeSentRecords(root, rest, now)
33
+ console.log(`closed ${closed}`)
34
+ return 0
35
+ }
@@ -1,6 +1,7 @@
1
+ import { twoDigits } from '../plan-format/clock'
1
2
  import { firstTableWithHeader, sectionLines } from '../plan-format/markdown'
3
+ import { isRecord } from '../shared/is-record'
2
4
  import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
- import { isRecord } from './json-guards'
4
5
 
5
6
  export const APPOINTMENT_EVENT = { due: 'due', unreadable: 'appointment unreadable' } as const
6
7
 
@@ -90,8 +91,6 @@ export const parseAppointmentTime = (text: string, now: Date): Date | null => {
90
91
  return parts.year === null ? nearestToClock(parts, now) : localTime(parts, parts.year)
91
92
  }
92
93
 
93
- const twoDigits = (value: number): string => String(value).padStart(2, '0')
94
-
95
94
  /** The local time to the minute and the action: a moved appointment gets a new key, a reworded day name does not. */
96
95
  export const appointmentKey = (when: Date, action: string): string =>
97
96
  `${when.getFullYear()}-${twoDigits(when.getMonth() + 1)}-${twoDigits(when.getDate())}T${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())} ${action}`
@@ -132,10 +131,11 @@ const tableOfRow = (
132
131
  * listed as unreadable as a whole, with its header and the missing names; a section without a table has no rows.
133
132
  */
134
133
  export const readAppointmentTable = (board: string, language: PlanLanguage, now: Date): AppointmentTable => {
135
- const { heading, columns } = APPOINTMENT_VOCABULARY[language]
134
+ const { heading, columns, statusAlso } = APPOINTMENT_VOCABULARY[language]
136
135
  const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
137
136
  if (header.length === 0) return { appointments: [], unreadable: [] }
138
- const needed = [columns.when, columns.action, columns.status]
137
+ const statusName = [columns.status, ...statusAlso].find((name) => header.includes(name)) ?? columns.status
138
+ const needed = [columns.when, columns.action, statusName]
139
139
  const missing = needed.filter((name) => !header.includes(name))
140
140
  if (missing.length > 0)
141
141
  return {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { isRecord } from './json-guards'
3
+ import { MS_PER_MINUTE } from '../plan-format/clock'
4
+ import { isRecord } from '../shared/is-record'
4
5
 
5
6
  export const APPROVAL_EVENT = 'approval'
6
7
 
@@ -49,7 +50,6 @@ export const isApprovalRequestFile = (value: unknown): value is ApprovalRequestF
49
50
  (value.usedAt === null || typeof value.usedAt === 'number')
50
51
 
51
52
  const REPORT_MINUTES = 30
52
- const MS_PER_MINUTE = 60_000
53
53
 
54
54
  /** One event per open request younger than 30 minutes the watcher has not reported yet; an older one is stale. */
55
55
  export const approvalEvents = (
@@ -37,11 +37,14 @@ import { contextReport } from '../context/report'
37
37
  import { agentLog } from '../context/session-log'
38
38
  import { type LaneProgress, parseSprintLanes, type SprintLane } from '../plan-format'
39
39
  import { terminalSafe } from '../plan-format/terminal-text'
40
+ import { openWaitingItems } from '../plan-format/waiting'
40
41
  import { boardFor, foundLanesOf } from '../plans/board-pick'
41
42
  import { ensurePlanPanes } from '../plans/ensure-panes'
42
43
  import { errorText } from '../plans/error-text'
43
44
  import { mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
44
45
  import { discoverLanes, isUnchanged, laneSetChange, laneSetLines } from '../plans/lane-discovery'
46
+ import { sentWaitingItems } from '../plans/sent-files'
47
+ import { readSentRecords } from '../plans/sent-files-read'
45
48
  import { CLOCK_LOCALE, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
46
49
  import { boardTexts } from '../plans/texts'
47
50
  import { defaultUpdateCheck } from '../sync/if-changed'
@@ -107,6 +110,7 @@ import {
107
110
  watchEventLine,
108
111
  } from './lane-report'
109
112
  import { laneReportPath, readLaneReport, reportMtime } from './lane-report-file'
113
+ import { restNote } from './lane-rest'
110
114
  import { branchOf, fetchBaseWhenMoved } from './lane-sources'
111
115
  import {
112
116
  advanceBaseline,
@@ -148,8 +152,17 @@ import { runSource, SOURCE_LIMITS } from './source-run'
148
152
  import { recordWatchedSources } from './source-tickets'
149
153
  import { isSourceDue, type SourceRun, type SourceSettings, type SourceState, sourceEvents } from './sources'
150
154
  import { streamHeartbeatLine } from './stream-output'
155
+ import {
156
+ formatTaskParallelLine,
157
+ TASK_PARALLEL_EVENT,
158
+ type TaskParallelEvent,
159
+ type TaskParallelFingerprints,
160
+ taskParallelReport,
161
+ } from './task-parallel'
162
+ import { readTaskParallelInputs } from './task-parallel-read'
151
163
  import { type WatchTexts, watchTexts } from './texts'
152
164
  import { updateAnnouncer } from './update-available'
165
+ import { waitingLine } from './waiting-line'
153
166
  import {
154
167
  coordinatorWatchEvent,
155
168
  deployEvents,
@@ -184,6 +197,7 @@ type Files = { state: string; lock: string }
184
197
  type FeedbackConfig = JackherdConfig['watch']['feedback']
185
198
 
186
199
  type ChecksConfig = JackherdConfig['watch']['checks']
200
+ type ParallelTasksConfig = JackherdConfig['watch']['parallelTasks']
187
201
 
188
202
  /** Where deploy previews are read from; undefined when the project reads none. */
189
203
  type PreviewsConfig = JackherdConfig['watch']['previews']
@@ -203,6 +217,7 @@ type Watch = {
203
217
  deploy: DeployConfig | undefined
204
218
  feedback: FeedbackConfig
205
219
  checks: ChecksConfig
220
+ parallelTasks: ParallelTasksConfig
206
221
  previews: PreviewsConfig
207
222
  sources: SourceSettings[]
208
223
  texts: WatchTexts
@@ -260,6 +275,8 @@ type Watching = {
260
275
  laneModelReadings: Record<string, LaneModelReading>
261
276
  /** The phases of each lane's plan that can run together at the last read; recorded with every state like the models. */
262
277
  laneParallelReadings: Record<string, string[]>
278
+ /** The parallel-task fingerprint of each lane at the last heartbeat; persisted like the lane parallels. */
279
+ taskParallels: TaskParallelFingerprints
263
280
  /** The approval requests of the last poll; null before the first read, so the stored ids stay. */
264
281
  approvalRequests: ApprovalRequestFile[] | null
265
282
  /** The board's appointment table at the last poll and the time it was read at; null before the first read and while the board cannot be read. */
@@ -299,6 +316,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
299
316
  laneLevels: recordLaneLevels(withReads.laneLevels, watching.laneReadings, watching.lanes),
300
317
  laneModels: recordLaneModels(withReads.laneModels, watching.laneModelReadings, watching.lanes),
301
318
  laneParallels: recordLaneParallels(withReads.laneParallels, watching.laneParallelReadings, watching.lanes),
319
+ taskParallels: watching.taskParallels,
302
320
  approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
303
321
  sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
304
322
  appointments: watching.appointments
@@ -340,6 +358,12 @@ const screenOf = (lane: string): string => {
340
358
  const reportPathOf = (watch: Watch, lane: string): string | null =>
341
359
  laneReportPath(worktreeOf(watch.root, watch.settings, lane), lane)
342
360
 
361
+ /** What a resting lane waits on, from its report file (`restNote`); null while it works or when it asks nothing. */
362
+ const restNoteOf = (watch: Watch, lane: string, status: string | undefined): string | null =>
363
+ status !== undefined && IDLE_STATUSES.has(status)
364
+ ? restNote(readLaneReport(reportPathOf(watch, lane))?.text ?? null)
365
+ : null
366
+
343
367
  /** The lines under a stopped lane's header and its report time to remember (`laneMessage`). */
344
368
  const stopMessageOf = (watch: Watch, lane: string, status: string, state: WatchState) =>
345
369
  laneMessage({
@@ -451,6 +475,7 @@ const boardReadOf = async (
451
475
 
452
476
  /** What the status line of one lane is made of, from the loop's state. */
453
477
  const laneReportOf = (
478
+ watch: Watch,
454
479
  lane: string,
455
480
  watching: Watching,
456
481
  state: WatchState,
@@ -467,6 +492,7 @@ const laneReportOf = (
467
492
  pullRequest: pullRequest ? { number: pullRequest.number, checks: pullRequest.checks, feedback } : null,
468
493
  progress,
469
494
  context: watching.laneReadings[lane]?.level ?? null,
495
+ rest: restNoteOf(watch, lane, watching.current[lane]?.status),
470
496
  }
471
497
  }
472
498
 
@@ -500,16 +526,17 @@ const statusLinesOf = async (
500
526
  const others = otherPullRequestsLine(pullRequests, github?.own ?? [], watch.texts)
501
527
  const { progress, checkoutRows, notes } = await boardReadOrNote(watch, watching.lanes)
502
528
  const laneLines = watching.lanes.map((lane) =>
503
- laneReportLine(laneReportOf(lane, watching, state, pullRequests, progress[lane] ?? null), watch.texts),
529
+ laneReportLine(laneReportOf(watch, lane, watching, state, pullRequests, progress[lane] ?? null), watch.texts),
504
530
  )
505
531
  const outage = state.ghDownSince === null ? [] : [ghUnavailableStatusLine(clock(state.ghDownSince))]
532
+ const waiting = waitingLineOf(watch)
506
533
  const board = { term: watch.settings.board.term, checkoutRows }
507
534
  const coordinator = coordinatorStatusLine(watching.coordinator.name, watching.coordinator.read, watch.texts, board)
508
535
  return {
509
536
  laneLines,
510
537
  coordinator,
511
538
  others: others === null ? [] : [others],
512
- rest: [...notes, ...outage, ...failingSourceLines(state.sources)],
539
+ rest: [...notes, ...(waiting === null ? [] : [waiting]), ...outage, ...failingSourceLines(state.sources)],
513
540
  }
514
541
  }
515
542
 
@@ -813,6 +840,13 @@ const readAppointments = (watch: Watch, now: number): Watching['appointments'] =
813
840
  return { table: readAppointmentTable(board, watch.settings.planLanguage, new Date(now)), readAt: now }
814
841
  }
815
842
 
843
+ /** The line of what waits on the user's answer, from the board's own section; null when nothing is open or the board cannot be read. */
844
+ const waitingLineOf = (watch: Watch): string | null => {
845
+ const board = readBoardText(watch)
846
+ const sends = sentWaitingItems(readSentRecords(watch.root), Date.now())
847
+ return waitingLine([...(board === null ? [] : openWaitingItems(board, watch.settings.planLanguage)), ...sends])
848
+ }
849
+
816
850
  /** The board's lanes, null when the board cannot be read. */
817
851
  const readSprintLanes = (watch: Watch): SprintLane[] | null => {
818
852
  const board = readBoardText(watch)
@@ -896,6 +930,39 @@ const report = async (
896
930
  return shown
897
931
  }
898
932
 
933
+ /**
934
+ * The parallel-task proposals of this heartbeat, one event per open lane whose situation changed since the last
935
+ * heartbeat and that has a proposal, and the fingerprints of every lane read, kept for the next one. Nothing when
936
+ * herdr or the board cannot be read: a proposal is a hint, and the next heartbeat reads again.
937
+ */
938
+ const taskParallelEventsOf = (
939
+ watch: Watch,
940
+ watching: Watching,
941
+ now: number,
942
+ ): { events: TaskParallelEvent[]; fingerprints: TaskParallelFingerprints } => {
943
+ try {
944
+ const lanes = (readSprintLanes(watch) ?? []).filter((lane) => !lane.closed && watching.lanes.includes(lane.name))
945
+ const inputs = readTaskParallelInputs(lanes, herdrLaneStatusSource.listAgents(), {
946
+ root: watch.root,
947
+ settings: watch.settings,
948
+ boardFile: watch.sprintFile,
949
+ thresholdTokens: watch.parallelTasks.maxTokens,
950
+ now,
951
+ })
952
+ const reports = Object.entries(inputs).map(
953
+ ([lane, input]) => [lane, taskParallelReport(watching.taskParallels[lane], input)] as const,
954
+ )
955
+ return {
956
+ events: reports.flatMap(([lane, { proposal }]) =>
957
+ proposal ? [{ kind: TASK_PARALLEL_EVENT, lane, line: formatTaskParallelLine(lane, proposal) }] : [],
958
+ ),
959
+ fingerprints: Object.fromEntries(reports.map(([lane, { fingerprint }]) => [lane, fingerprint])),
960
+ }
961
+ } catch {
962
+ return { events: [], fingerprints: {} }
963
+ }
964
+ }
965
+
899
966
  /**
900
967
  * The heartbeat of a stream: one line, a working lane with its phase count when that moved since the last heartbeat.
901
968
  * The plans are read only when a lane works; a board that cannot be read leaves the lines without a count, and the
@@ -907,7 +974,16 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
907
974
  const counts = currentPhaseCountsOf(progress)
908
975
  const notes = progressNotes(watching.lanes, watching.current, counts, watching.heartbeatCounts)
909
976
  watching.heartbeatCounts = { ...watching.heartbeatCounts, ...counts }
910
- console.log(streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes))
977
+ const rests = Object.fromEntries(
978
+ watching.lanes.flatMap((lane) => {
979
+ const rest = restNoteOf(watch, lane, watching.current[lane]?.status)
980
+ return rest === null ? [] : [[lane, rest]]
981
+ }),
982
+ )
983
+ console.log(streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes, rests, waitingLineOf(watch)))
984
+ const { events, fingerprints } = taskParallelEventsOf(watch, watching, now)
985
+ watching.taskParallels = { ...watching.taskParallels, ...fingerprints }
986
+ for (const event of events) console.log(watchEventLine(event, null))
911
987
  }
912
988
 
913
989
  /**
@@ -941,9 +1017,17 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
941
1017
  next = await report(watch, watching, stopped, polledEvents, next)
942
1018
  watching.baseline = { ...watching.current }
943
1019
  } else if (heartbeat && !stream) {
944
- next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }], { ...next, statusLines: null })
1020
+ const { events: proposals, fingerprints } = taskParallelEventsOf(watch, watching, now)
1021
+ watching.taskParallels = { ...watching.taskParallels, ...fingerprints }
1022
+ next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }, ...proposals], {
1023
+ ...recordedOf(watch, watching),
1024
+ statusLines: null,
1025
+ })
945
1026
  } else {
946
- if (heartbeat) await printStreamHeartbeat(watch, watching, now)
1027
+ if (heartbeat) {
1028
+ await printStreamHeartbeat(watch, watching, now)
1029
+ next = recordedOf(watch, watching)
1030
+ }
947
1031
  if (due) writePrivate(watch.files.state, JSON.stringify(next))
948
1032
  watching.baseline = advanceBaseline(watching.baseline, watching.current)
949
1033
  }
@@ -983,6 +1067,7 @@ const initialWatching = (
983
1067
  laneReadings: {},
984
1068
  laneModelReadings: {},
985
1069
  laneParallelReadings: {},
1070
+ taskParallels: start.state.taskParallels,
986
1071
  approvalRequests: null,
987
1072
  appointments: null,
988
1073
  planViews: { failureReported: false },
@@ -1048,6 +1133,7 @@ const watchOf = (options: WatchOptions): Watch => {
1048
1133
  deploy: config.watch.deploy,
1049
1134
  feedback: config.watch.feedback,
1050
1135
  checks: config.watch.checks,
1136
+ parallelTasks: config.watch.parallelTasks,
1051
1137
  previews: config.watch.previews,
1052
1138
  sources: config.watch.sources,
1053
1139
  texts: watchTexts(settings.responseLanguage),