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
@@ -1,4 +1,5 @@
1
1
  import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { stripAnsi } from '../terminal/text'
2
3
 
3
4
  /** The most failed tests kept for one head commit. */
4
5
  export const FAILED_TEST_LIMIT = 50
@@ -29,24 +30,69 @@ const compiled = (pattern: string): RegExp | null => {
29
30
  }
30
31
  }
31
32
 
33
+ /** `places` holds, by test name, the package or test file the log printed before the test; a test before any has none. */
34
+ export type FailedTestScan = { tests: string[]; places: Record<string, string> }
35
+
36
+ /** `##[group]`, which GitHub Actions folds a section behind, in front of a header line. */
37
+ const GROUP_MARK = /^##\[group\]/
38
+
39
+ /** The task header a monorepo runner (turbo) prints before a package's run: `@scope/name:test`, the package first. */
40
+ const TASK_HEADER = /^(@[\w.-]+\/[\w.-]+|[\w.-]+):[\w:-]*test[\w:-]*$/
41
+
42
+ /** The header Bun prints before the results of one test file: its path and a colon. */
43
+ const FILE_HEADER = /^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/
44
+
45
+ /** Where the scan stands in the log: the package of the current task and the test file of the current run. */
46
+ type ScanPlace = { task: string | null; file: string | null }
47
+
48
+ /** The place after `line`: a task header starts a new package, a file header a new file. */
49
+ const placeAfter = (place: ScanPlace, line: string): ScanPlace => {
50
+ const header = stripAnsi(line).replace(GROUP_MARK, '')
51
+ const task = TASK_HEADER.exec(header)?.[1]
52
+ if (task) return { task: nameOf(task), file: null }
53
+ const file = FILE_HEADER.exec(header)?.[1]
54
+ return file ? { ...place, file: nameOf(file) } : place
55
+ }
56
+
57
+ const cleanLine = (raw: string): string => raw.replace(/\r$/, '').replace(LOG_PREFIX, '')
58
+
32
59
  /**
33
- * The names of the failed tests in the log `gh run view <id> --log-failed` printed, in the order first seen. Each line
34
- * loses its job, step and timestamp prefix, then `pattern` (a regular expression) is matched against it: the first
35
- * group is the name, else the whole match. A test the log names twice (Bun lists it in the run and in the summary) is
36
- * listed once. Names are made terminal-safe and cut; at most `FAILED_TEST_LIMIT` are kept. A pattern that is no
37
- * regular expression finds nothing.
60
+ * The failed tests in the log `gh run view <id> --log-failed` printed, in the order first seen, with the place each
61
+ * was first seen in. Each line loses its job, step and timestamp prefix, then `pattern` (a regular expression) is
62
+ * matched against it: the first group is the name, else the whole match. The place is the package of the task header
63
+ * before it (`@scope/name:test`), else the test file Bun printed before it. A test the log names twice (Bun lists it
64
+ * in the run and in the summary) is listed once, at its first place. Names and places are made terminal-safe and cut;
65
+ * at most `FAILED_TEST_LIMIT` are kept. A pattern that is no regular expression finds nothing.
38
66
  */
39
- export const extractFailedTests = (log: string, pattern: string): string[] => {
67
+ export const scanFailedTests = (log: string, pattern: string): FailedTestScan => {
40
68
  const expression = compiled(pattern)
41
- if (!expression) return []
69
+ const places: Record<string, string> = {}
70
+ if (!expression) return { tests: [], places }
42
71
  const names = new Set<string>()
72
+ let place: ScanPlace = { task: null, file: null }
43
73
  for (const raw of log.split('\n')) {
44
74
  if (names.size >= FAILED_TEST_LIMIT) break
45
- const line = raw.replace(/\r$/, '').replace(LOG_PREFIX, '')
75
+ const line = cleanLine(raw)
46
76
  if (line.length > MAX_LINE_LENGTH) continue
77
+ place = placeAfter(place, line)
47
78
  const match = expression.exec(line)
48
79
  const name = nameOf(match?.[1] ?? match?.[0] ?? '')
49
- if (name !== '') names.add(name)
80
+ if (name === '' || names.has(name)) continue
81
+ names.add(name)
82
+ const where = place.task ?? place.file
83
+ if (where) places[name] = where
50
84
  }
51
- return [...names]
85
+ return { tests: [...names], places }
52
86
  }
87
+
88
+ /** `places` read back from the state file, for the names in `names` only, made terminal-safe and cut like the names. */
89
+ export const sanitisedFailedTestPlaces = (
90
+ places: Readonly<Record<string, string>>,
91
+ names: readonly string[],
92
+ ): Record<string, string> =>
93
+ Object.fromEntries(
94
+ Object.entries(places).flatMap(([name, place]) => {
95
+ const shown = nameOf(name)
96
+ return names.includes(shown) && nameOf(place) !== '' ? [[shown, nameOf(place)]] : []
97
+ }),
98
+ )
@@ -1,6 +1,6 @@
1
- import { extractFailedTests, FAILED_TEST_LIMIT } from './failed-tests'
1
+ import { isRecord } from '../shared/is-record'
2
+ import { FAILED_TEST_LIMIT, scanFailedTests } from './failed-tests'
2
3
  import { type GhAnswer, runGh } from './gh'
3
- import { isRecord } from './json-guards'
4
4
 
5
5
  /** How long `gh run view --log-failed` may take before the call is given up; a read that timed out is tried again. */
6
6
  export const LOG_TIMEOUT_MS = 30_000
@@ -8,8 +8,13 @@ export const LOG_TIMEOUT_MS = 30_000
8
8
  /** Of a longer log only this many characters from the end are kept; Bun repeats its failures in the summary there. */
9
9
  export const LOG_TAIL_CHARS = 2_000_000
10
10
 
11
- /** `running` is true when a run was still going, which is no failure of the read and is not counted as an attempt. */
12
- export type FailedTestsRead = { ok: true; tests: string[] } | { ok: false; running: boolean }
11
+ /**
12
+ * `running` is true when a run was still going, which is no failure of the read and is not counted as an attempt.
13
+ * `places` names, by test, the package or test file the logs printed before it; absent when they named none.
14
+ */
15
+ export type FailedTestsRead =
16
+ | { ok: true; tests: string[]; places?: Record<string, string> }
17
+ | { ok: false; running: boolean }
13
18
 
14
19
  type RunGh = (args: string[]) => Promise<GhAnswer>
15
20
 
@@ -51,6 +56,13 @@ export const readFailedTests = async (
51
56
  const answers = await Promise.all(runIds.map((runId) => run(logArgs(runId))))
52
57
  const logs = answers.flatMap((answer) => (answer.ok ? [answer.stdout] : []))
53
58
  if (logs.length < answers.length) return { ok: false, running: false }
54
- const tests = new Set(logs.flatMap((text) => extractFailedTests(text, pattern)))
55
- return { ok: true, tests: [...tests].slice(0, FAILED_TEST_LIMIT) }
59
+ const scans = logs.map((text) => scanFailedTests(text, pattern))
60
+ const tests = [...new Set(scans.flatMap((scan) => scan.tests))].slice(0, FAILED_TEST_LIMIT)
61
+ const places = Object.fromEntries(
62
+ tests.flatMap((name) => {
63
+ const place = scans.find((scan) => scan.places[name] !== undefined)?.places[name]
64
+ return place === undefined ? [] : [[name, place]]
65
+ }),
66
+ )
67
+ return { ok: true, tests, ...(Object.keys(places).length > 0 && { places }) }
56
68
  }
@@ -1,14 +1,21 @@
1
1
  import { CHECKS } from '../plans/pull-requests'
2
+ import { isRecord } from '../shared/is-record'
2
3
  import type { FailedTestsRead } from './failure-read'
3
- import { isRecord } from './json-guards'
4
4
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
5
5
 
6
6
  /**
7
7
  * What the watcher read of a red pull request's logs: the tests that failed on `headCommit`. `read` is true once the
8
8
  * logs answered (with no test among them, too), `attempts` counts the reads tried, so a run that was still going when
9
- * it was asked is asked again, a few times, and no head commit is read more than once it has answered.
9
+ * it was asked is asked again, a few times, and no head commit is read more than once it has answered. `places` names,
10
+ * by test, the package or test file the log printed before it; absent in a state written before it was read.
10
11
  */
11
- export type FailureRecord = { headCommit: string; tests: string[]; read: boolean; attempts: number }
12
+ export type FailureRecord = {
13
+ headCommit: string
14
+ tests: string[]
15
+ read: boolean
16
+ attempts: number
17
+ places?: Record<string, string>
18
+ }
12
19
 
13
20
  /** Pull request number → what was read for it; kept for the open pull requests only. */
14
21
  export type FailureState = Record<string, FailureRecord>
@@ -27,13 +34,17 @@ export const FAILURE_RUN_LIMIT = 3
27
34
 
28
35
  export const emptyFailureState = (): FailureState => ({})
29
36
 
37
+ const isPlaces = (value: unknown): boolean =>
38
+ value === undefined || (isRecord(value) && Object.values(value).every((place) => typeof place === 'string'))
39
+
30
40
  const isFailureRecord = (value: unknown): boolean =>
31
41
  isRecord(value) &&
32
42
  typeof value.headCommit === 'string' &&
33
43
  Array.isArray(value.tests) &&
34
44
  value.tests.every((test) => typeof test === 'string') &&
35
45
  typeof value.read === 'boolean' &&
36
- typeof value.attempts === 'number'
46
+ typeof value.attempts === 'number' &&
47
+ isPlaces(value.places)
37
48
 
38
49
  export const isFailureState = (value: unknown): value is FailureState =>
39
50
  isRecord(value) && Object.values(value).every(isFailureRecord)
@@ -78,7 +89,14 @@ export const failureReadingOf = (
78
89
  result: FailedTestsRead,
79
90
  ): FailureReading =>
80
91
  result.ok
81
- ? { number, headCommit, tests: result.tests, read: true, attempts: attempts + 1 }
92
+ ? {
93
+ number,
94
+ headCommit,
95
+ tests: result.tests,
96
+ read: true,
97
+ attempts: attempts + 1,
98
+ ...(result.places && { places: result.places }),
99
+ }
82
100
  : { number, headCommit, tests: [], read: false, attempts: result.running ? attempts : attempts + 1 }
83
101
 
84
102
  /**
@@ -105,3 +123,9 @@ export const failedTestsOf = (state: FailureState, number: number, headCommit: s
105
123
  const record = state[String(number)]
106
124
  return record?.headCommit === headCommit ? record.tests : []
107
125
  }
126
+
127
+ /** The places of the failed tests read for `headCommit` of the pull request; none for any other head commit. */
128
+ export const failedTestPlacesOf = (state: FailureState, number: number, headCommit: string): Record<string, string> => {
129
+ const record = state[String(number)]
130
+ return record?.headCommit === headCommit ? (record.places ?? {}) : {}
131
+ }
@@ -1,5 +1,5 @@
1
+ import { isRecord } from '../shared/is-record'
1
2
  import { FEEDBACK_FORM, type FeedbackForm, type FeedbackItem } from './feedback-item'
2
- import { isRecord } from './json-guards'
3
3
  import { isNoFindingComment, ROUND_WINDOW_MS } from './no-finding'
4
4
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
5
5
 
@@ -7,9 +7,10 @@ export type GhAnswer = { ok: true; stdout: string } | { ok: false; error: string
7
7
  * `timeoutMs` ends a call that has not finished by then; without it the call waits as long as `gh` does.
8
8
  * `tailChars` keeps only that many characters from the end of the output: stdout is read as a stream and never held
9
9
  * whole, so memory stays near twice `tailChars` however much `gh` prints. `executable` replaces the `gh` that is run,
10
- * for a test that stands in for it.
10
+ * for a test that stands in for it. `stdin` is written to the process's standard input, for a request body too large for
11
+ * the command line.
11
12
  */
12
- export type GhOptions = { timeoutMs?: number; tailChars?: number; executable?: string }
13
+ export type GhOptions = { timeoutMs?: number; tailChars?: number; executable?: string; stdin?: string }
13
14
 
14
15
  const readTail = async (stream: ReadableStream<Uint8Array>, tailChars: number): Promise<string> => {
15
16
  const decoder = new TextDecoder()
@@ -30,7 +31,12 @@ export const runGh = async (args: string[], cwd: string, options: GhOptions = {}
30
31
  let timedOut = false
31
32
  let timer: ReturnType<typeof setTimeout> | undefined
32
33
  try {
33
- const child = Bun.spawn([options.executable ?? 'gh', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
34
+ const child = Bun.spawn([options.executable ?? 'gh', ...args], {
35
+ cwd,
36
+ stdin: options.stdin === undefined ? 'ignore' : new Blob([options.stdin]),
37
+ stdout: 'pipe',
38
+ stderr: 'pipe',
39
+ })
34
40
  if (options.timeoutMs !== undefined)
35
41
  timer = setTimeout(() => {
36
42
  timedOut = true
@@ -1,6 +1,3 @@
1
- export const isRecord = (value: unknown): value is Record<string, unknown> =>
2
- typeof value === 'object' && value !== null && !Array.isArray(value)
3
-
4
1
  /** The entries of a JSON array when every one passes `isValid`; throws `<source>: unexpected answer` otherwise. */
5
2
  export const parseArray = <T>(json: string, isValid: (entry: unknown) => entry is T, source: string): T[] => {
6
3
  const answer: unknown = JSON.parse(json)
@@ -1,6 +1,6 @@
1
1
  import type { ContextLevel, ContextReading } from '../context'
2
+ import { isRecord } from '../shared/is-record'
2
3
  import { isContextLevel, levelStep } from './coordinator-fill'
3
- import { isRecord } from './json-guards'
4
4
 
5
5
  export const LANE_FILL_EVENT = 'lane'
6
6
 
@@ -1,5 +1,5 @@
1
1
  import { differsFromColumn } from '../plan-format'
2
- import { isRecord } from './json-guards'
2
+ import { isRecord } from '../shared/is-record'
3
3
 
4
4
  export const LANE_MODEL_EVENT = 'model'
5
5
 
@@ -1,4 +1,4 @@
1
- import { isRecord } from './json-guards'
1
+ import { isRecord } from '../shared/is-record'
2
2
 
3
3
  export const LANE_PARALLEL_EVENT = 'parallel'
4
4
 
@@ -32,23 +32,35 @@ export const watchedLaneProgress = async (
32
32
  }
33
33
 
34
34
  /**
35
- * The phases of a lane's plan (the plan of its newest row, as the lane's worktree has it, else as the base ref has
36
- * it) that can run together, by the plan's dependency table and ticks; empty without a plan, without a table or
37
- * without two such phases.
35
+ * The text of a lane's plan, the plan of its newest row, as the lane's worktree has it, else as the base ref has it;
36
+ * null without a row that links a plan or when neither has the file.
38
37
  */
39
- export const watchedLaneParallel = (
38
+ export const watchedLanePlanText = (
40
39
  root: string,
41
40
  settings: PlansSettings,
42
41
  boardFile: string,
43
42
  lane: SprintLane,
44
- ): string[] => {
43
+ ): string | null => {
45
44
  const href = lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref
46
- if (!href) return []
45
+ if (!href) return null
47
46
  const path = resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href)
48
47
  const worktreeFile = join(worktreeOf(root, settings, lane.name), path)
49
- const text = existsSync(worktreeFile)
48
+ return existsSync(worktreeFile)
50
49
  ? readFileSync(worktreeFile, 'utf8')
51
50
  : textAtBase(baseReaderOf(root, baseRefOf(settings)), path)
51
+ }
52
+
53
+ /**
54
+ * The phases of a lane's plan that can run together, by the plan's dependency table and ticks; empty without a plan,
55
+ * without a table or without two such phases.
56
+ */
57
+ export const watchedLaneParallel = (
58
+ root: string,
59
+ settings: PlansSettings,
60
+ boardFile: string,
61
+ lane: SprintLane,
62
+ ): string[] => {
63
+ const text = watchedLanePlanText(root, settings, boardFile, lane)
52
64
  const groups = text === null ? null : parallelGroupsOfPlan(text, settings.planLanguage)
53
65
  return groups === null ? [] : parallelBatchOf(groups)
54
66
  }
@@ -33,6 +33,8 @@ export type LaneReport = {
33
33
  progress: LaneProgress | null
34
34
  /** The context level last read for the lane's agent; null without a value. */
35
35
  context: ContextLevel | null
36
+ /** What a resting lane waits on, from its report (`restNote`); absent or null while it works or asks nothing. */
37
+ rest?: string | null
36
38
  }
37
39
 
38
40
  /** The board as the coordinator line reminds of it: its term and the rows only the checkout's board file has. */
@@ -75,13 +77,14 @@ const contextSegment = (level: ContextLevel | null): string[] =>
75
77
  level === null || level === CONTEXT_LEVEL.ok ? [] : [`context ${level}`]
76
78
 
77
79
  /**
78
- * One line per lane, in the words of `texts`: `<lane> <status> [· context warn|alert] · <branch>|no branch ·
80
+ * One line per lane, in the words of `texts`: `<lane> <status> [· context warn|alert] [· waits on: <question>, <n> open questions] · <branch>|no branch ·
79
81
  * PR #<n> <checks>|no PR · open phase …|phase … ticked, not merged|plan finished|no plan`.
80
82
  */
81
83
  export const laneReportLine = (report: LaneReport, texts: WatchTexts): string =>
82
84
  [
83
85
  `${terminalSafe(report.lane)} ${terminalSafe(report.status)}`,
84
86
  ...contextSegment(report.context),
87
+ ...(report.rest ? [terminalSafe(report.rest)] : []),
85
88
  report.branch ? terminalSafe(report.branch) : texts.noBranch,
86
89
  pullRequestSegment(report.pullRequest, texts),
87
90
  phaseSegment(report.progress, texts),
@@ -150,6 +153,7 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
150
153
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${terminalSafe(event.model)} · board ${terminalSafe(event.column)}`
151
154
  if (event.kind === WATCH_EVENT.laneParallel)
152
155
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
156
+ if (event.kind === WATCH_EVENT.taskParallel) return `=== ${terminalSafe(event.line)}`
153
157
  if (event.kind === WATCH_EVENT.approval)
154
158
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
155
159
  if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
@@ -0,0 +1,28 @@
1
+ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
2
+ import { questionCount, questionSections } from './report-questions'
3
+
4
+ const LIST_MARKER = /^(?:\d+[.)]|[-*])\s*/
5
+ const QUESTION_LENGTH = 60
6
+
7
+ /** The first question as one line: list marker and bold out, made safe, cut. */
8
+ const firstQuestion = (lines: readonly string[], start: number, end: number): string => {
9
+ const body = lines
10
+ .slice(start + 1, end)
11
+ .map((line) => line.trim().replace(LIST_MARKER, '').replaceAll('**', '').trim())
12
+ .find((line) => line !== '')
13
+ return ellipsize(terminalSafe(body ?? ''), QUESTION_LENGTH)
14
+ }
15
+
16
+ /**
17
+ * What a resting lane (idle or done) is waiting for, from its report: `waits on: <first question>, <n> open
18
+ * questions`; null without a report or without an open question, so a lane that has nothing to ask stays `idle`.
19
+ */
20
+ export const restNote = (reportText: string | null): string | null => {
21
+ if (reportText === null) return null
22
+ const lines = reportText.split('\n')
23
+ const sections = questionSections(lines)
24
+ const [first] = sections
25
+ if (!first) return null
26
+ const count = questionCount(lines, sections)
27
+ return `waits on: ${firstQuestion(lines, first.start, first.end)}, ${count} open ${count === 1 ? 'question' : 'questions'}`
28
+ }
@@ -1,6 +1,6 @@
1
1
  import { CHECKS } from '../plans/pull-requests'
2
- import { sanitisedFailedTests } from './failed-tests'
3
- import { failedTestsOf } from './failures'
2
+ import { sanitisedFailedTestPlaces, sanitisedFailedTests } from './failed-tests'
3
+ import { failedTestPlacesOf, failedTestsOf } from './failures'
4
4
  import { previewUrlOf } from './previews'
5
5
  import { PULL_REQUEST_STATE } from './pull-requests'
6
6
  import { lineText, type TicketRecord } from './sources'
@@ -16,6 +16,8 @@ export type PullRequestBadges = {
16
16
  changesRequested: boolean
17
17
  /** The tests a red run failed on this head commit, as the watcher read them from the run's log; absent reads as none. */
18
18
  failedTests?: string[]
19
+ /** By failed test, the package or test file the log printed before it; a test without an entry has no known place. */
20
+ failedTestPlaces?: Record<string, string>
19
21
  }
20
22
 
21
23
  /** The badges of a pull request the watcher shows nothing for. */
@@ -82,11 +84,16 @@ export const watchBadgesOf = (state: WatchState, now: number): WatchBadges => {
82
84
  const preview = state.previews.pullRequests[number]
83
85
  const feedback = state.feedback.pullRequests[number]
84
86
  const failedTests = checks === CHECKS.failed ? shownFailedTestsOf(state, number, headCommit) : []
87
+ const places = sanitisedFailedTestPlaces(
88
+ failedTestPlacesOf(state.failures, Number(number), headCommit),
89
+ failedTests,
90
+ )
85
91
  const badges = {
86
92
  preview: preview?.reportedHead === headCommit ? (preview.url ?? null) : null,
87
93
  feedback: feedback?.headCommit === headCommit ? feedback.count : 0,
88
94
  changesRequested: feedback?.changesRequested === true,
89
95
  ...(failedTests.length > 0 && { failedTests }),
96
+ ...(Object.keys(places).length > 0 && { failedTestPlaces: places }),
90
97
  }
91
98
  const shown = badges.preview !== null || badges.feedback > 0 || badges.changesRequested || failedTests.length > 0
92
99
  return shown ? [[number, badges]] : []
@@ -1,6 +1,6 @@
1
+ import { isRecord } from '../shared/is-record'
1
2
  import { type FeedbackTarget, isReadTime } from './feedback'
2
3
  import { FEEDBACK_FORM, type FeedbackItem } from './feedback-item'
3
- import { isRecord } from './json-guards'
4
4
 
5
5
  export const PREVIEW_EVENT = 'preview'
6
6
 
@@ -7,7 +7,8 @@ import {
7
7
  type GhPullRequest,
8
8
  isGhPullRequest,
9
9
  } from '../plans/pull-requests'
10
- import { isRecord, parseArray } from './json-guards'
10
+ import { isRecord } from '../shared/is-record'
11
+ import { parseArray } from './json-guards'
11
12
 
12
13
  /** The states of a pull request in `gh pr list --json state`. */
13
14
  export const PULL_REQUEST_STATE = { open: 'OPEN', merged: 'MERGED', closed: 'CLOSED' } as const
@@ -1,4 +1,4 @@
1
- import { isRecord } from './json-guards'
1
+ import { isRecord } from '../shared/is-record'
2
2
 
3
3
  /**
4
4
  * Where a field sits in a source's JSON output: `.` is the whole output, else a chain of `.key`, `[n]` and `[-n]`
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { terminalSafe } from '../plan-format/terminal-text'
3
- import { isRecord } from './json-guards'
3
+ import { isRecord } from '../shared/is-record'
4
4
  import { previewUrlOf } from './previews'
5
5
  import { CONTENT_FIELDS, SOURCE_FIELD } from './source-fields'
6
6
  import { ROOT_PATH, type SourcePath, selectPath } from './source-path'
@@ -5,11 +5,12 @@ import { type LaneSnapshot, NO_AGENT } from './lane-watch'
5
5
  import { WATCH_EVENT } from './watch-events'
6
6
 
7
7
  const SEPARATOR = ' · '
8
+ const RESTING: ReadonlySet<string> = new Set([AGENT_STATUS.idle, AGENT_STATUS.done])
8
9
 
9
- /** `lane status`, a working lane with its note: `lane working: P12 7/12`. */
10
- const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes): string => {
10
+ /** `lane status`, a working lane with its progress, a resting one with what it waits on: `lane idle: waits on: …`. */
11
+ const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes, rests: LaneNotes): string => {
11
12
  const status = current[lane]?.status ?? NO_AGENT
12
- const note = status === AGENT_STATUS.working ? notes[lane] : undefined
13
+ const note = status === AGENT_STATUS.working ? notes[lane] : RESTING.has(status) ? rests[lane] : undefined
13
14
  const head = `${terminalSafe(lane)} ${terminalSafe(status)}`
14
15
  return note === undefined ? head : `${head}: ${terminalSafe(note).replaceAll(SEPARATOR, ' / ')}`
15
16
  }
@@ -23,9 +24,13 @@ export const streamHeartbeatLine = (
23
24
  lanes: readonly string[],
24
25
  current: LaneSnapshot,
25
26
  notes: LaneNotes,
27
+ rests: LaneNotes = {},
28
+ waiting: string | null = null,
26
29
  ): string => {
27
- const states = lanes.map((lane) => laneState(lane, current, notes))
28
- return [`=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`, ...(states.length > 0 ? states : ['no lanes'])].join(
29
- SEPARATOR,
30
- )
30
+ const states = lanes.map((lane) => laneState(lane, current, notes, rests))
31
+ return [
32
+ `=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`,
33
+ ...(states.length > 0 ? states : ['no lanes']),
34
+ ...(waiting === null ? [] : [terminalSafe(waiting).replaceAll(SEPARATOR, ' / ')]),
35
+ ].join(SEPARATOR)
31
36
  }
@@ -0,0 +1,118 @@
1
+ import { isPhaseFinished, isPhaseOpen, type PlanLanguage, type PlanPhase, parsePlanPhases } from '../plan-format'
2
+ import { type PhaseDependency, parseDependencyTable } from '../plan-format/dependencies'
3
+ import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
4
+ import type { TaskSignal } from '../task-signals/types'
5
+ import type { ActiveWork, OpenTask, TaskParallelInput } from './task-parallel'
6
+
7
+ /** The tasks a lane could start from its plan: open items of the phases that are ready, with the writes of the table. */
8
+ export type PlanTaskView = {
9
+ open: OpenPlanTask[]
10
+ doneCount: number
11
+ /** Phase id → the paths its row writes, `wie N` cells resolved; a phase whose set cannot be resolved has none. */
12
+ phaseWrites: Record<string, string[]>
13
+ }
14
+
15
+ const writeSetOf = (
16
+ row: PhaseDependency,
17
+ byId: ReadonlyMap<string, PhaseDependency>,
18
+ chain: readonly string[],
19
+ ): string[] | null => {
20
+ if (chain.includes(row.id)) return null
21
+ const like = row.writesLike.map((id) => {
22
+ const target = byId.get(id)
23
+ return target ? writeSetOf(target, byId, [...chain, row.id]) : null
24
+ })
25
+ return like.some((set) => set === null) ? null : [...row.writes, ...like.flatMap((set) => set ?? [])]
26
+ }
27
+
28
+ const phaseWritesOf = (rows: readonly PhaseDependency[]): Record<string, string[]> => {
29
+ const byId = new Map(rows.map((row) => [row.id, row]))
30
+ return Object.fromEntries(
31
+ rows.flatMap((row) => {
32
+ const writes = writeSetOf(row, byId, [])
33
+ return writes === null ? [] : [[row.id, writes]]
34
+ }),
35
+ )
36
+ }
37
+
38
+ const isReady = (row: PhaseDependency, doneIds: ReadonlySet<string>): boolean =>
39
+ !doneIds.has(row.id) && row.external.length === 0 && row.after.every((id) => doneIds.has(id))
40
+
41
+ /** The phases whose tasks may start: ready by the dependency table, else the first open phase of a plan without one. */
42
+ const readyPhaseIds = (phases: readonly PlanPhase[], table: readonly PhaseDependency[] | null): ReadonlySet<string> => {
43
+ const doneIds = new Set(phases.filter(isPhaseFinished).map(({ id }) => id))
44
+ if (table === null)
45
+ return new Set(
46
+ phases
47
+ .filter(isPhaseOpen)
48
+ .slice(0, 1)
49
+ .map(({ id }) => id),
50
+ )
51
+ return new Set(table.filter((row) => isReady(row, doneIds)).map(({ id }) => id))
52
+ }
53
+
54
+ const openTasksOf = (phase: PlanPhase): OpenPlanTask[] =>
55
+ phase.items.flatMap((item, taskIndex) =>
56
+ item.done || item.gate ? [] : [{ phaseId: phase.id, taskIndex, text: item.text }],
57
+ )
58
+
59
+ /** The open tasks a lane could start from `markdown`, its ticked count and the writes of its phases. */
60
+ export const planTaskViewOf = (markdown: string, language: PlanLanguage): PlanTaskView => {
61
+ const phases = parsePlanPhases(markdown, language)
62
+ const table = parseDependencyTable(markdown, language)
63
+ const ready = readyPhaseIds(phases, table)
64
+ return {
65
+ open: phases.filter(({ id }) => ready.has(id)).flatMap(openTasksOf),
66
+ doneCount: phases.reduce((count, phase) => count + phase.items.filter((item) => item.done).length, 0),
67
+ phaseWrites: table === null ? {} : phaseWritesOf(table),
68
+ }
69
+ }
70
+
71
+ export type TaskParallelFacts = {
72
+ lane: string
73
+ plan: PlanTaskView
74
+ /** What agents in the lane's worktree report they work on, the coordinator's subagents included. */
75
+ signals: readonly TaskSignal[]
76
+ /** Files the work in the lane's worktree wrote lately, relative to it. */
77
+ touchedFiles: readonly string[]
78
+ /** The lane's own agent is working, with or without a task report. */
79
+ laneWorking: boolean
80
+ baselineTokens: number
81
+ thresholdTokens: number
82
+ }
83
+
84
+ const activeWorkOf = (facts: TaskParallelFacts): ActiveWork[] => {
85
+ const matched = matchSignals(facts.plan.open, facts.signals)
86
+ const work: ActiveWork[] = matched.map(({ signal, task }) => ({
87
+ name: signal.agent,
88
+ files: [],
89
+ taskText: signal.text,
90
+ ...(task ? { phase: task.phaseId } : {}),
91
+ }))
92
+ if (work.length === 0 && facts.laneWorking) work.push({ name: facts.lane, files: [] })
93
+ const [first, ...rest] = work
94
+ return first ? [{ ...first, files: [...facts.touchedFiles] }, ...rest] : []
95
+ }
96
+
97
+ /** The tasks a running signal already works on are not open any more. */
98
+ const openTasksWithout = (facts: TaskParallelFacts): OpenTask[] => {
99
+ const taken = new Set(
100
+ matchSignals(facts.plan.open, facts.signals).flatMap(({ task }) =>
101
+ task ? [`${task.phaseId}:${task.taskIndex}`] : [],
102
+ ),
103
+ )
104
+ return facts.plan.open
105
+ .filter((task) => !taken.has(`${task.phaseId}:${task.taskIndex}`))
106
+ .map(({ phaseId, text }) => ({ phase: phaseId, text }))
107
+ }
108
+
109
+ /** The input of the pure core from what the watcher read. */
110
+ export const taskParallelInputOf = (facts: TaskParallelFacts): TaskParallelInput => ({
111
+ lane: facts.lane,
112
+ open: openTasksWithout(facts),
113
+ active: activeWorkOf(facts),
114
+ phaseWrites: facts.plan.phaseWrites,
115
+ doneCount: facts.plan.doneCount,
116
+ baselineTokens: facts.baselineTokens,
117
+ thresholdTokens: facts.thresholdTokens,
118
+ })