jackherd 1.8.1 → 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.
- package/CHANGELOG.md +30 -0
- package/INSTALL.md +2 -0
- package/README.md +37 -44
- package/dist/{chunk-OU2KALXP.js → chunk-MCP4XJQH.js} +15 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +19 -3
- package/dist/plan-format.d.ts +19 -2
- package/dist/plan-format.js +21 -7
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +67 -20
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +18 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks.json +9 -0
- package/kit/hooks/statusline.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +2 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +30 -6
- package/package.json +2 -1
- package/schemas/jackherd.schema.json +26 -2
- package/src/cli.ts +3 -0
- package/src/config/schema.ts +22 -2
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/plan-format/clock.ts +4 -0
- package/src/plan-format/dependencies.ts +2 -5
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +48 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/lane-standing.ts +9 -4
- package/src/plans/lane-summary.ts +141 -74
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +57 -18
- package/src/plans/layout-summary.ts +85 -67
- package/src/plans/layout.ts +253 -89
- package/src/plans/live.ts +22 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/pull-requests.ts +6 -0
- package/src/plans/selection.ts +19 -16
- package/src/plans/sent-files-read.ts +22 -0
- package/src/plans/sent-files.ts +121 -0
- package/src/plans/signals-read.ts +54 -0
- package/src/plans/snapshot.ts +251 -55
- package/src/plans/subagents-read.ts +82 -0
- package/src/plans/subagents.ts +132 -0
- package/src/plans/texts.ts +16 -11
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +3 -3
- package/src/plans/waiting-click.ts +76 -0
- package/src/preview/command.ts +83 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-records.ts +48 -0
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +72 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +8 -1
- package/src/terminal/width.ts +2 -1
- package/src/vocabulary/index.ts +28 -0
- package/src/waiting/command.ts +35 -0
- package/src/watch/appointments.ts +2 -3
- package/src/watch/approvals.ts +2 -2
- package/src/watch/command.ts +91 -5
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +19 -7
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +2 -1
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +1 -1
- package/src/watch/stream-output.ts +12 -7
- package/src/watch/task-parallel-input.ts +118 -0
- package/src/watch/task-parallel-read.ts +149 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +206 -0
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +13 -1
- package/src/watch/workflow-runs.ts +2 -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
|
+
}
|
package/src/terminal/open.ts
CHANGED
|
@@ -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>> = {
|
|
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
|
|
11
|
-
export const
|
|
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
|
|
31
|
-
if (!
|
|
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,
|
|
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)}`
|
package/src/terminal/text.ts
CHANGED
|
@@ -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(
|
|
51
|
+
teal: style(tealCodeOf(process.env)),
|
|
45
52
|
cyan: style('36'),
|
|
46
53
|
inverse: style('7'),
|
|
47
54
|
}
|
package/src/terminal/width.ts
CHANGED
|
@@ -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
|
}
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -93,6 +93,34 @@ export const APPOINTMENT_VOCABULARY: Record<
|
|
|
93
93
|
},
|
|
94
94
|
}
|
|
95
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: [],
|
|
120
|
+
open: ['open'],
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
96
124
|
export const isPlanLanguage = (value: string): value is PlanLanguage =>
|
|
97
125
|
(Object.values(PLAN_LANGUAGE) as string[]).includes(value)
|
|
98
126
|
|
|
@@ -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}`
|
package/src/watch/approvals.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import {
|
|
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 = (
|
package/src/watch/command.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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),
|
|
@@ -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
|
|
34
|
-
* loses its job, step and timestamp prefix, then `pattern` (a regular expression) is
|
|
35
|
-
* group is the name, else the whole match.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
67
|
+
export const scanFailedTests = (log: string, pattern: string): FailedTestScan => {
|
|
40
68
|
const expression = compiled(pattern)
|
|
41
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
12
|
-
|
|
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
|
|
55
|
-
|
|
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
|
}
|