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
package/src/watch/failures.ts
CHANGED
|
@@ -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 = {
|
|
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
|
-
? {
|
|
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
|
+
}
|
package/src/watch/feedback.ts
CHANGED
|
@@ -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
|
|
package/src/watch/gh-process.ts
CHANGED
|
@@ -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], {
|
|
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
|
package/src/watch/json-guards.ts
CHANGED
|
@@ -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)
|
package/src/watch/lane-fill.ts
CHANGED
package/src/watch/lane-model.ts
CHANGED
|
@@ -32,23 +32,35 @@ export const watchedLaneProgress = async (
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* The
|
|
36
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/watch/plan-badges.ts
CHANGED
|
@@ -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]] : []
|
package/src/watch/previews.ts
CHANGED
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
type GhPullRequest,
|
|
8
8
|
isGhPullRequest,
|
|
9
9
|
} from '../plans/pull-requests'
|
|
10
|
-
import { isRecord
|
|
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
|
package/src/watch/source-path.ts
CHANGED
package/src/watch/sources.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
-
import { isRecord } from '
|
|
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
|
|
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 [
|
|
29
|
-
|
|
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
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { firstAnswerTokens } from '../context/fill'
|
|
4
|
+
import { agentLogPath, readHeadLines, readTailLines, TAIL_BYTES } from '../context/session-log'
|
|
5
|
+
import type { SprintLane } from '../plan-format'
|
|
6
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
7
|
+
import { STALE_ACTIVITY_MINUTES } from '../plans/lane-summary'
|
|
8
|
+
import { type PlansSettings, worktreeOf } from '../plans/settings'
|
|
9
|
+
import { readClaudeSignals } from '../task-signals/claude-read'
|
|
10
|
+
import { recordSignals } from '../task-signals/record'
|
|
11
|
+
import { readTaskRecords } from '../task-signals/record-store'
|
|
12
|
+
import type { TaskSignal } from '../task-signals/types'
|
|
13
|
+
import { AGENT_STATUS, type LaneAgent } from '../terminal/types'
|
|
14
|
+
import { watchedLanePlanText } from './lane-progress'
|
|
15
|
+
import type { TaskParallelInput } from './task-parallel'
|
|
16
|
+
import { planTaskViewOf, taskParallelInputOf } from './task-parallel-input'
|
|
17
|
+
import { isWithinWorktree, touchedFilesOf } from './task-parallel-touched'
|
|
18
|
+
|
|
19
|
+
const RECENT_WRITE_MS = 30 * 60_000
|
|
20
|
+
const HEAD_BYTES = 256 * 1024
|
|
21
|
+
const SUBAGENT_LOG = /^agent-[A-Za-z0-9]+\.jsonl$/
|
|
22
|
+
/** How many subagent logs of one session have their writes read, the newest first. */
|
|
23
|
+
const MAX_SUBAGENT_LOGS = 20
|
|
24
|
+
|
|
25
|
+
/** One agent's session: its log, the signals in it and its subagents, and the tails of the logs the writes are read from. */
|
|
26
|
+
type AgentRead = { agent: LaneAgent; logPath: string | null; signals: TaskSignal[]; tails: string[][] }
|
|
27
|
+
|
|
28
|
+
const modifiedAt = (path: string): number => {
|
|
29
|
+
try {
|
|
30
|
+
return statSync(path).mtimeMs
|
|
31
|
+
} catch {
|
|
32
|
+
return 0
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The newest `MAX_SUBAGENT_LOGS` subagent logs of a session, by modification time, so a long session bounds the read. */
|
|
37
|
+
const subagentLogPaths = (logPath: string): string[] => {
|
|
38
|
+
const directory = join(logPath.replace(/\.jsonl$/, ''), 'subagents')
|
|
39
|
+
try {
|
|
40
|
+
return existsSync(directory)
|
|
41
|
+
? readdirSync(directory)
|
|
42
|
+
.filter((name) => SUBAGENT_LOG.test(name))
|
|
43
|
+
.map((name) => ({ path: join(directory, name), modified: modifiedAt(join(directory, name)) }))
|
|
44
|
+
.sort((left, right) => right.modified - left.modified)
|
|
45
|
+
.slice(0, MAX_SUBAGENT_LOGS)
|
|
46
|
+
.map(({ path }) => path)
|
|
47
|
+
: []
|
|
48
|
+
} catch {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tailOf = (path: string): string[] => {
|
|
54
|
+
try {
|
|
55
|
+
return readTailLines(path, TAIL_BYTES)
|
|
56
|
+
} catch {
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const readAgent = (agent: LaneAgent, projectsDirectory: string | undefined, now: number): AgentRead => {
|
|
62
|
+
const logPath = agentLogPath(agent, projectsDirectory)
|
|
63
|
+
const notOlderThan = now - STALE_ACTIVITY_MINUTES * MS_PER_MINUTE
|
|
64
|
+
return {
|
|
65
|
+
agent,
|
|
66
|
+
logPath,
|
|
67
|
+
signals: logPath ? readClaudeSignals(logPath, { notOlderThan }) : [],
|
|
68
|
+
tails: logPath ? [logPath, ...subagentLogPaths(logPath)].map(tailOf) : [],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const baselineCache = new Map<string, number | null>()
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What a fresh agent carries before it has worked, measured as `jh context` measures a fill: the input of the first
|
|
76
|
+
* answer in a session log (system prompt, project instructions and the first request). The smallest honest source on
|
|
77
|
+
* hand: a log of a started session, not a guess; cached per log, since the head of a log never changes.
|
|
78
|
+
*/
|
|
79
|
+
const baselineOf = (logPath: string): number | null => {
|
|
80
|
+
if (!baselineCache.has(logPath)) {
|
|
81
|
+
try {
|
|
82
|
+
baselineCache.set(logPath, firstAnswerTokens(readHeadLines(logPath, HEAD_BYTES)))
|
|
83
|
+
} catch {
|
|
84
|
+
baselineCache.set(logPath, null)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return baselineCache.get(logPath) ?? null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const baselineFor = (lane: string, reads: readonly AgentRead[]): number | null => {
|
|
91
|
+
const own = reads.find(({ agent }) => agent.name === lane)
|
|
92
|
+
const sources = [own, ...reads.filter((read) => read !== own)]
|
|
93
|
+
for (const read of sources) {
|
|
94
|
+
const tokens = read?.logPath ? baselineOf(read.logPath) : null
|
|
95
|
+
if (tokens !== null) return tokens
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const uniqueSignals = (signals: readonly TaskSignal[]): TaskSignal[] => [
|
|
101
|
+
...new Map(signals.map((signal) => [`${signal.agent}|${signal.text}`, signal])).values(),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
export type TaskParallelSources = {
|
|
105
|
+
root: string
|
|
106
|
+
settings: PlansSettings
|
|
107
|
+
boardFile: string
|
|
108
|
+
thresholdTokens: number
|
|
109
|
+
now: number
|
|
110
|
+
/** Where Claude Code keeps its session logs; the user's by default. */
|
|
111
|
+
projectsDirectory?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The input of the parallel-task check for each of `lanes`, read from the plan of the lane's newest row, the sessions
|
|
116
|
+
* of every listed agent (a lane's work, and the coordinator's subagents in the lane's worktree) and the records of
|
|
117
|
+
* `jh task`. A lane without a plan, without a token baseline, without a task left or without active work (a working
|
|
118
|
+
* agent, a running subagent or an open record in its worktree) is not in the answer.
|
|
119
|
+
*/
|
|
120
|
+
export const readTaskParallelInputs = (
|
|
121
|
+
lanes: readonly SprintLane[],
|
|
122
|
+
agents: readonly LaneAgent[],
|
|
123
|
+
sources: TaskParallelSources,
|
|
124
|
+
): Record<string, TaskParallelInput> => {
|
|
125
|
+
const reads = agents.map((agent) => readAgent(agent, sources.projectsDirectory, sources.now))
|
|
126
|
+
const records = recordSignals(readTaskRecords(sources.root))
|
|
127
|
+
const since = sources.now - RECENT_WRITE_MS
|
|
128
|
+
return Object.fromEntries(
|
|
129
|
+
lanes.flatMap((lane) => {
|
|
130
|
+
const text = watchedLanePlanText(sources.root, sources.settings, sources.boardFile, lane)
|
|
131
|
+
const baselineTokens = baselineFor(lane.name, reads)
|
|
132
|
+
if (text === null || baselineTokens === null) return []
|
|
133
|
+
const worktree = worktreeOf(sources.root, sources.settings, lane.name)
|
|
134
|
+
const signals = uniqueSignals([...reads.flatMap((read) => read.signals), ...records]).filter((signal) =>
|
|
135
|
+
isWithinWorktree(signal.cwd, worktree),
|
|
136
|
+
)
|
|
137
|
+
const input = taskParallelInputOf({
|
|
138
|
+
lane: lane.name,
|
|
139
|
+
plan: planTaskViewOf(text, sources.settings.planLanguage),
|
|
140
|
+
signals,
|
|
141
|
+
touchedFiles: reads.flatMap((read) => read.tails.flatMap((tail) => touchedFilesOf(tail, worktree, since))),
|
|
142
|
+
laneWorking: agents.some((agent) => agent.name === lane.name && agent.status === AGENT_STATUS.working),
|
|
143
|
+
baselineTokens,
|
|
144
|
+
thresholdTokens: sources.thresholdTokens,
|
|
145
|
+
})
|
|
146
|
+
return input.open.length === 0 || input.active.length === 0 ? [] : [[lane.name, input]]
|
|
147
|
+
}),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { isAbsolute, relative } from 'node:path'
|
|
2
|
+
import { parseLogEntry } from '../context/session-log'
|
|
3
|
+
import { isRecord } from '../shared/is-record'
|
|
4
|
+
|
|
5
|
+
const WRITING_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
|
|
6
|
+
|
|
7
|
+
const writtenPathsOf = (entry: Record<string, unknown>): string[] => {
|
|
8
|
+
const content = isRecord(entry.message) && Array.isArray(entry.message.content) ? entry.message.content : []
|
|
9
|
+
return content.flatMap((block) => {
|
|
10
|
+
if (!isRecord(block) || block.type !== 'tool_use' || !WRITING_TOOLS.has(String(block.name))) return []
|
|
11
|
+
const input = isRecord(block.input) ? block.input : {}
|
|
12
|
+
const path = input.file_path ?? input.notebook_path
|
|
13
|
+
return typeof path === 'string' ? [path] : []
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const insideWorktree = (path: string, worktree: string): string | null => {
|
|
18
|
+
const inside = relative(worktree, path)
|
|
19
|
+
return inside === '' || inside.startsWith('..') || isAbsolute(inside) ? null : inside
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether `path` is the worktree or lies below it. */
|
|
23
|
+
export const isWithinWorktree = (path: string, worktree: string): boolean =>
|
|
24
|
+
insideWorktree(path, worktree) !== null || path === worktree
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The files the log lines show written (`Edit`, `Write`, `MultiEdit`, `NotebookEdit`) at or after `sinceMs` inside
|
|
28
|
+
* `worktree`, relative to it and without repeats, in order of first write. A path outside the worktree, a line that is
|
|
29
|
+
* no JSON and a write without a time are left out.
|
|
30
|
+
*/
|
|
31
|
+
export const touchedFilesOf = (lines: readonly string[], worktree: string, sinceMs: number): string[] => {
|
|
32
|
+
const touched = lines.flatMap((line) => {
|
|
33
|
+
const entry = parseLogEntry(line)
|
|
34
|
+
const time = typeof entry?.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
|
|
35
|
+
if (!entry || Number.isNaN(time) || time < sinceMs) return []
|
|
36
|
+
return writtenPathsOf(entry).flatMap((path) => insideWorktree(path, worktree) ?? [])
|
|
37
|
+
})
|
|
38
|
+
return [...new Set(touched)]
|
|
39
|
+
}
|