jackherd 1.8.1 → 1.10.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 +60 -0
- package/INSTALL.md +3 -1
- package/README.md +75 -64
- package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
- package/dist/config.d.ts +45 -4
- package/dist/config.js +84 -27
- package/dist/plan-format.d.ts +63 -7
- package/dist/plan-format.js +66 -12
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- 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/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- 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 +9 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +21 -4
- package/kit/hooks/statusline.sh +1 -1
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +10 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +4 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +85 -7
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +4 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +80 -26
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +12 -0
- package/src/plan-format/dependencies.ts +9 -9
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +53 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-standing.ts +9 -4
- package/src/plans/lane-summary.ts +367 -92
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +80 -19
- package/src/plans/layout-summary.ts +163 -74
- package/src/plans/layout.ts +472 -114
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +119 -10
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +23 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +117 -34
- package/src/plans/sent-files-read.ts +13 -0
- package/src/plans/sent-files.ts +114 -0
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +62 -0
- package/src/plans/snapshot.ts +279 -60
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +131 -0
- package/src/plans/subagents.ts +190 -0
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +36 -14
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +20 -12
- package/src/plans/waiting-click.ts +231 -0
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +105 -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-location.ts +89 -0
- package/src/shared/private-records.ts +48 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +89 -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 +16 -4
- package/src/terminal/width.ts +2 -1
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +44 -6
- package/src/waiting/command.ts +50 -0
- package/src/watch/appointments.ts +2 -4
- package/src/watch/approvals.ts +2 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +401 -90
- 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/heartbeat-progress.ts +6 -18
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +58 -14
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +37 -5
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +8 -3
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +7 -4
- package/src/watch/stream-output.ts +14 -8
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +108 -0
- package/src/watch/task-parallel-read.ts +151 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +235 -0
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +36 -1
- package/src/watch/watcher-lock.ts +25 -29
- package/src/watch/workflow-runs.ts +2 -1
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -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
|
}
|
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
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { currentPhase, type LaneProgress, PHASE_ID_PREFIX, type PhaseProgress } from '../plan-format'
|
|
2
|
-
import { AGENT_STATUS } from '../terminal/types'
|
|
3
|
-
import type { LaneSnapshot } from './lane-watch'
|
|
4
2
|
|
|
5
3
|
/** The current phase of a lane and how many of its checkboxes are ticked, as the herd counts them. */
|
|
6
4
|
export type PhaseCount = Pick<PhaseProgress, 'id' | 'done' | 'total'>
|
|
@@ -23,25 +21,15 @@ export const currentPhaseCountsOf = (progress: Readonly<Record<string, LaneProgr
|
|
|
23
21
|
}),
|
|
24
22
|
)
|
|
25
23
|
|
|
26
|
-
const sameCount = (left: PhaseCount | undefined, right: PhaseCount): boolean =>
|
|
27
|
-
left?.id === right.id && left.done === right.done && left.total === right.total
|
|
28
|
-
|
|
29
24
|
/**
|
|
30
|
-
* The note of each working lane
|
|
31
|
-
*
|
|
25
|
+
* The note of each working lane that has a count, at every heartbeat: a stretch of reviews or subagents without a new
|
|
26
|
+
* tick still shows where the lane stands, and an unchanged number tells the coordinator the lane is not stuck on
|
|
27
|
+
* silence.
|
|
32
28
|
*/
|
|
33
|
-
export const progressNotes = (
|
|
34
|
-
lanes: readonly string[],
|
|
35
|
-
current: LaneSnapshot,
|
|
36
|
-
counts: Readonly<Record<string, PhaseCount>>,
|
|
37
|
-
previous: Readonly<Record<string, PhaseCount>>,
|
|
38
|
-
): LaneNotes =>
|
|
29
|
+
export const progressNotes = (working: readonly string[], counts: Readonly<Record<string, PhaseCount>>): LaneNotes =>
|
|
39
30
|
Object.fromEntries(
|
|
40
|
-
|
|
31
|
+
working.flatMap((lane) => {
|
|
41
32
|
const count = counts[lane]
|
|
42
|
-
|
|
43
|
-
return moved && current[lane]?.status === AGENT_STATUS.working
|
|
44
|
-
? [[lane, `${PHASE_ID_PREFIX}${count.id} ${count.done}/${count.total}`]]
|
|
45
|
-
: []
|
|
33
|
+
return count !== undefined ? [[lane, `${PHASE_ID_PREFIX}${count.id} ${count.done}/${count.total}`]] : []
|
|
46
34
|
}),
|
|
47
35
|
)
|
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/kit-update.ts
CHANGED
|
@@ -1,18 +1,37 @@
|
|
|
1
|
-
import { kitUpdatedLine, pendingKitUpdate } from '../version/installed-version'
|
|
1
|
+
import { type KitUpdate, kitUpdatedLine, pendingKitUpdate } from '../version/installed-version'
|
|
2
2
|
import type { StartedKit } from '../version/started-kit'
|
|
3
3
|
|
|
4
|
-
type Effects = {
|
|
4
|
+
type Effects = {
|
|
5
|
+
saveState: () => void
|
|
6
|
+
log: (line: string) => void
|
|
7
|
+
skillLine: () => string
|
|
8
|
+
/** Whether the source the watcher runs from changed and the change settled; always false for an installed copy. */
|
|
9
|
+
sourceChanged: () => boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The line that ends the watcher: a new version on disk, else a settled change to the source it runs from, which
|
|
14
|
+
* keeps the version (`=== kit updated 1.9.0 → 1.9.0 (source changed)`); null while neither.
|
|
15
|
+
*/
|
|
16
|
+
export const kitEndLine = (update: KitUpdate | null, version: string, sourceChanged: boolean): string | null => {
|
|
17
|
+
if (update) return kitUpdatedLine(update)
|
|
18
|
+
return sourceChanged ? `${kitUpdatedLine({ from: version, to: version })} (source changed)` : null
|
|
19
|
+
}
|
|
5
20
|
|
|
6
21
|
/**
|
|
7
|
-
* Ends the watcher when the kit on disk is another version than the one it started with
|
|
8
|
-
* as any stop does, then prints the line that wakes the coordinator and the line about
|
|
9
|
-
* skill. True when the watcher should end.
|
|
22
|
+
* Ends the watcher when the kit on disk is another version than the one it started with, or when the source it runs
|
|
23
|
+
* from changed: saves the state as any stop does, then prints the line that wakes the coordinator and the line about
|
|
24
|
+
* the orchestrate skill. True when the watcher should end.
|
|
10
25
|
*/
|
|
11
|
-
export const endedByKitUpdate = (
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
export const endedByKitUpdate = (
|
|
27
|
+
kit: StartedKit | null,
|
|
28
|
+
{ saveState, log, skillLine, sourceChanged }: Effects,
|
|
29
|
+
): boolean => {
|
|
30
|
+
if (!kit) return false
|
|
31
|
+
const line = kitEndLine(pendingKitUpdate(kit), kit.version, sourceChanged())
|
|
32
|
+
if (line === null) return false
|
|
14
33
|
saveState()
|
|
15
|
-
log(
|
|
34
|
+
log(line)
|
|
16
35
|
log(skillLine())
|
|
17
36
|
return true
|
|
18
37
|
}
|
package/src/watch/lane-fill.ts
CHANGED
package/src/watch/lane-model.ts
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lane name that stays a single file name: no separator, no `..`. The prompt, report and hand-over files are named
|
|
3
|
+
* after the lane, and the watcher puts it on a suggested command line, so every reader of a lane name checks it
|
|
4
|
+
* against this.
|
|
5
|
+
*/
|
|
6
|
+
export const SAFE_LANE_NAME = /^(?!\.\.?$)[A-Za-z0-9._-]+$/
|
|
@@ -2,53 +2,97 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
import { type LaneProgress, parallelGroupsOfPlan, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
4
4
|
import { boardRowsOf } from '../plans/board-rows'
|
|
5
|
-
import { baseReaderOf, gitStatusOf } from '../plans/git'
|
|
5
|
+
import { type BaseReader, baseReaderOf, gitStatusOf } from '../plans/git'
|
|
6
6
|
import { laneWorkOf, readPlans, textAtBase } from '../plans/lane-plans'
|
|
7
|
+
import { laneHeads, type PlanPullRequest, planPullRequests } from '../plans/plan-heads'
|
|
7
8
|
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
|
|
8
9
|
import { workedLaneProgress } from '../plans/worked-phases'
|
|
9
10
|
import { parallelBatchOf } from './lane-parallel'
|
|
11
|
+
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
12
|
+
|
|
13
|
+
/** The open pull requests of the watcher's list that change a plan, as the herd chooses them. */
|
|
14
|
+
export const watchedPlanPullRequests = (
|
|
15
|
+
pullRequests: readonly WatchedPullRequest[],
|
|
16
|
+
settings: PlansSettings,
|
|
17
|
+
): WatchedPullRequest[] =>
|
|
18
|
+
planPullRequests(
|
|
19
|
+
pullRequests.filter(({ state }) => state === PULL_REQUEST_STATE.open),
|
|
20
|
+
settings.planDirectory,
|
|
21
|
+
)
|
|
10
22
|
|
|
11
23
|
/**
|
|
12
|
-
* The progress the watcher reports for a lane, from the same reading as the herd: the base ref's plans,
|
|
13
|
-
*
|
|
24
|
+
* The progress the watcher reports for a lane, from the same reading as the herd: the base ref's plans, raised by
|
|
25
|
+
* the fetched heads of `openPlanPullRequests` (`watchedPlanPullRequests`), the phases the lane works on from its
|
|
26
|
+
* worktree (also without a board row), so both name the same open phase.
|
|
14
27
|
*/
|
|
15
28
|
export const watchedLaneProgress = async (
|
|
16
29
|
root: string,
|
|
17
30
|
settings: PlansSettings,
|
|
18
31
|
boardFile: string,
|
|
19
32
|
lane: SprintLane,
|
|
33
|
+
openPlanPullRequests: readonly PlanPullRequest[],
|
|
34
|
+
headReaderOf: (ref: string) => BaseReader = (ref) => baseReaderOf(root, ref),
|
|
20
35
|
): Promise<LaneProgress> => {
|
|
21
36
|
const baseRef = baseRefOf(settings)
|
|
22
37
|
const worktree = worktreeOf(root, settings, lane.name)
|
|
38
|
+
const status = await gitStatusOf(worktree, baseRef)
|
|
23
39
|
const sources = {
|
|
24
40
|
root,
|
|
25
41
|
settings,
|
|
26
42
|
base: baseReaderOf(root, baseRef),
|
|
27
43
|
sprintDirectory: dirname(boardRepoPathOf(root, settings, boardFile)),
|
|
44
|
+
heads: laneHeads(openPlanPullRequests, status.branch, headReaderOf),
|
|
28
45
|
}
|
|
29
|
-
const
|
|
30
|
-
const { plans } = await readPlans(lane, sources, work, [])
|
|
46
|
+
const { plans } = await readPlans(lane, sources, laneWorkOf(worktree, status), [])
|
|
31
47
|
return workedLaneProgress(lane, plans)
|
|
32
48
|
}
|
|
33
49
|
|
|
50
|
+
/** The repository path of the plan of a lane's newest row that links one; null without such a row. */
|
|
51
|
+
const lanePlanPath = (root: string, settings: PlansSettings, boardFile: string, lane: SprintLane): string | null => {
|
|
52
|
+
const href = lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref
|
|
53
|
+
return href ? resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href) : null
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
/**
|
|
35
|
-
* The
|
|
36
|
-
*
|
|
37
|
-
* without two such phases.
|
|
57
|
+
* 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;
|
|
58
|
+
* null without a row that links a plan or when neither has the file.
|
|
38
59
|
*/
|
|
39
|
-
export const
|
|
60
|
+
export const watchedLanePlanText = (
|
|
40
61
|
root: string,
|
|
41
62
|
settings: PlansSettings,
|
|
42
63
|
boardFile: string,
|
|
43
64
|
lane: SprintLane,
|
|
44
|
-
): string
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
47
|
-
const path = resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href)
|
|
65
|
+
): string | null => {
|
|
66
|
+
const path = lanePlanPath(root, settings, boardFile, lane)
|
|
67
|
+
if (path === null) return null
|
|
48
68
|
const worktreeFile = join(worktreeOf(root, settings, lane.name), path)
|
|
49
|
-
|
|
69
|
+
return existsSync(worktreeFile)
|
|
50
70
|
? readFileSync(worktreeFile, 'utf8')
|
|
51
71
|
: textAtBase(baseReaderOf(root, baseRefOf(settings)), path)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The text of the same plan as the base ref has it, which the lane cannot change; null without a plan or the file. */
|
|
75
|
+
export const baseLanePlanText = (
|
|
76
|
+
root: string,
|
|
77
|
+
settings: PlansSettings,
|
|
78
|
+
boardFile: string,
|
|
79
|
+
lane: SprintLane,
|
|
80
|
+
): string | null => {
|
|
81
|
+
const path = lanePlanPath(root, settings, boardFile, lane)
|
|
82
|
+
return path === null ? null : textAtBase(baseReaderOf(root, baseRefOf(settings)), path)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The phases of a lane's plan that can run together, by the plan's dependency table and ticks; empty without a plan,
|
|
87
|
+
* without a table or without two such phases.
|
|
88
|
+
*/
|
|
89
|
+
export const watchedLaneParallel = (
|
|
90
|
+
root: string,
|
|
91
|
+
settings: PlansSettings,
|
|
92
|
+
boardFile: string,
|
|
93
|
+
lane: SprintLane,
|
|
94
|
+
): string[] => {
|
|
95
|
+
const text = watchedLanePlanText(root, settings, boardFile, lane)
|
|
52
96
|
const groups = text === null ? null : parallelGroupsOfPlan(text, settings.planLanguage)
|
|
53
97
|
return groups === null ? [] : parallelBatchOf(groups)
|
|
54
98
|
}
|
|
@@ -2,18 +2,27 @@ import { closeSync, lstatSync, openSync, readSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
|
|
4
4
|
import type { LaneReportFile } from './lane-message'
|
|
5
|
+
import { SAFE_LANE_NAME } from './lane-name'
|
|
5
6
|
|
|
6
|
-
/** A lane name that stays a single file name: no separator, no `..`. */
|
|
7
|
-
const SAFE_LANE_NAME = /^(?!\.\.?$)[A-Za-z0-9._-]+$/
|
|
8
7
|
/** The head of a report the watcher reads; it prints far less, and a huge file must not stall it. */
|
|
9
8
|
const READ_LIMIT_BYTES = 8192
|
|
10
9
|
|
|
10
|
+
/** Where a lane writes its report, relative to its worktree: `.claude/handover/<lane>-report.md`. */
|
|
11
|
+
export const laneReportFile = (lane: string): string => claudePath(HANDOVER_DIRECTORY, `${lane}-report.md`)
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
|
-
* Where a lane writes its report, inside its worktree
|
|
13
|
-
*
|
|
14
|
+
* Where a lane writes its report, inside its worktree (`laneReportFile`). Null for a lane name that could leave that
|
|
15
|
+
* directory: names come from board rows and herdr agents.
|
|
14
16
|
*/
|
|
15
17
|
export const laneReportPath = (worktree: string, lane: string): string | null =>
|
|
16
|
-
SAFE_LANE_NAME.test(lane) ? join(worktree,
|
|
18
|
+
SAFE_LANE_NAME.test(lane) ? join(worktree, laneReportFile(lane)) : null
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where a lane's hand-over lies, inside its worktree: `.claude/handover/<lane>.md`, which the coordinator opens a
|
|
22
|
+
* fresh chat on. Null for a lane name that could leave that directory.
|
|
23
|
+
*/
|
|
24
|
+
export const laneHandoverPath = (worktree: string, lane: string): string | null =>
|
|
25
|
+
SAFE_LANE_NAME.test(lane) ? join(worktree, claudePath(HANDOVER_DIRECTORY), `${lane}.md`) : null
|
|
17
26
|
|
|
18
27
|
/** The change time of a regular file at `path`, not followed through a symlink; null for anything else. */
|
|
19
28
|
const regularFileMtime = (path: string): number | null => {
|
|
@@ -35,6 +44,12 @@ const readHead = (path: string): string => {
|
|
|
35
44
|
}
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
/** Whether the lane left its hand-over file in the worktree: a regular file, not a link. */
|
|
48
|
+
export const hasLaneHandover = (worktree: string, lane: string): boolean => {
|
|
49
|
+
const path = laneHandoverPath(worktree, lane)
|
|
50
|
+
return path !== null && regularFileMtime(path) !== null
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
/** The change time of the report file; null when there is none or it is no regular file. */
|
|
39
54
|
export const reportMtime = (path: string | null): number | null => (path === null ? null : regularFileMtime(path))
|
|
40
55
|
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CONTEXT_LEVEL, type ContextLevel, contextLabel } from '../context'
|
|
2
|
+
import { closeDryRunOf } from '../lane/close-command-line'
|
|
2
3
|
import { currentPhase, isTickedUnmerged, type LaneProgress, phasesText } from '../plan-format'
|
|
3
4
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
5
|
import { CHECK_NAME_LENGTH, type ChecksState } from '../plans/pull-requests'
|
|
@@ -14,6 +15,7 @@ import { AGENT_STATUS, type LaneSnapshot, NO_AGENT } from './lane-watch'
|
|
|
14
15
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
15
16
|
import { SOURCE_FIELD } from './source-fields'
|
|
16
17
|
import { type FieldChange, lineText, type SourceEvent } from './sources'
|
|
18
|
+
import { NO_TASK_SIGNAL_EVENT, type NoTaskSignalEvent, noTaskSignalLine } from './task-signal-missing'
|
|
17
19
|
import type { WatchTexts } from './texts'
|
|
18
20
|
import {
|
|
19
21
|
type DeployEvent,
|
|
@@ -33,11 +35,14 @@ export type LaneReport = {
|
|
|
33
35
|
progress: LaneProgress | null
|
|
34
36
|
/** The context level last read for the lane's agent; null without a value. */
|
|
35
37
|
context: ContextLevel | null
|
|
38
|
+
/** What a resting lane waits on, from its report (`restNote`); absent or null while it works or asks nothing. */
|
|
39
|
+
rest?: string | null
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/** The board as the coordinator line reminds of it: its term and the rows only the checkout's board file has. */
|
|
39
43
|
export type BoardReminder = { term: string; checkoutRows: number }
|
|
40
44
|
|
|
45
|
+
const RESTING_STATUSES: ReadonlySet<string> = new Set([AGENT_STATUS.done, AGENT_STATUS.idle])
|
|
41
46
|
const UNKNOWN = 'unknown'
|
|
42
47
|
const REPORT_TEXT_LENGTH = 160
|
|
43
48
|
const FIELD_VALUE_LENGTH = 60
|
|
@@ -75,17 +80,34 @@ const contextSegment = (level: ContextLevel | null): string[] =>
|
|
|
75
80
|
level === null || level === CONTEXT_LEVEL.ok ? [] : [`context ${level}`]
|
|
76
81
|
|
|
77
82
|
/**
|
|
78
|
-
* One line per lane, in the words of `texts`: `<lane> <status> [· context warn|alert] · <branch>|no branch ·
|
|
79
|
-
* PR #<n> <checks>|no PR · open phase …|phase … ticked, not merged|plan finished|no plan`.
|
|
83
|
+
* One line per lane, in the words of `texts`: `<lane> <status> [· context warn|alert] [· waits on: <question>, <n> open questions] · <branch>|no branch ·
|
|
84
|
+
* PR #<n> <checks>|no PR · open phase …|phase … ticked, not merged|plan finished|no plan`. A resting lane whose plan is
|
|
85
|
+
* finished is followed by the dry run of `jh lane close` for it and `closeLaneHint`, the path of the reference on
|
|
86
|
+
* closing a lane.
|
|
80
87
|
*/
|
|
81
|
-
export const laneReportLine = (report: LaneReport, texts: WatchTexts): string =>
|
|
82
|
-
|
|
88
|
+
export const laneReportLine = (report: LaneReport, texts: WatchTexts, closeLaneHint = ''): string => {
|
|
89
|
+
const phase = phaseSegment(report.progress, texts)
|
|
90
|
+
const waitsForClosing = phase === texts.planFinished && RESTING_STATUSES.has(report.status)
|
|
91
|
+
const dryRun = closeDryRunOf(report.lane)
|
|
92
|
+
const closing = `${phase}${dryRun === null ? '' : ` · close: ${dryRun}`}${closeLaneHint}`
|
|
93
|
+
return [
|
|
83
94
|
`${terminalSafe(report.lane)} ${terminalSafe(report.status)}`,
|
|
84
95
|
...contextSegment(report.context),
|
|
96
|
+
...(report.rest ? [terminalSafe(report.rest)] : []),
|
|
85
97
|
report.branch ? terminalSafe(report.branch) : texts.noBranch,
|
|
86
98
|
pullRequestSegment(report.pullRequest, texts),
|
|
87
|
-
|
|
99
|
+
waitsForClosing ? closing : phase,
|
|
88
100
|
].join(' · ')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The header of a stopped lane's section: `=== <lane>: <status>`. A lane that stopped `done` or `idle` with a hand-over
|
|
105
|
+
* file in its worktree (`handoverHint`, null without one) names the file and the reference on it.
|
|
106
|
+
*/
|
|
107
|
+
export const laneStopHeader = (lane: string, status: string, handoverHint: string | null): string => {
|
|
108
|
+
const header = `=== ${terminalSafe(lane)}: ${terminalSafe(status)}`
|
|
109
|
+
return handoverHint !== null && RESTING_STATUSES.has(status) ? `${header} · hand-over file${handoverHint}` : header
|
|
110
|
+
}
|
|
89
111
|
|
|
90
112
|
/** The lane, or `branch <name>` for an own pull request without a lane, its name cut like other third-party text. */
|
|
91
113
|
const pullRequestOwner = (event: PullRequestEvent): string =>
|
|
@@ -150,6 +172,8 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
|
|
|
150
172
|
return `=== ${event.kind} ${terminalSafe(event.lane)} ${terminalSafe(event.model)} · board ${terminalSafe(event.column)}`
|
|
151
173
|
if (event.kind === WATCH_EVENT.laneParallel)
|
|
152
174
|
return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
|
|
175
|
+
if (event.kind === WATCH_EVENT.taskParallel) return `=== ${terminalSafe(event.line)}`
|
|
176
|
+
if (event.kind === WATCH_EVENT.noTaskSignal) return `=== ${terminalSafe(noTaskSignalLine(event))}`
|
|
153
177
|
if (event.kind === WATCH_EVENT.approval)
|
|
154
178
|
return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
|
|
155
179
|
if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
|
|
@@ -296,6 +320,14 @@ export const parallelDutyLines = (events: readonly WatchEvent[], texts: WatchTex
|
|
|
296
320
|
.filter(isLaneParallel)
|
|
297
321
|
.map(({ lane, phases }) => `${terminalSafe(lane)} parallel: ${texts.parallelDuty(phasesText(phases))}`)
|
|
298
322
|
|
|
323
|
+
const isNoTaskSignal = (event: WatchEvent): event is NoTaskSignalEvent => event.kind === WATCH_EVENT.noTaskSignal
|
|
324
|
+
|
|
325
|
+
/** What a lane that works without a task signal asks of the coordinator (`references/events.md`): one line per such lane. */
|
|
326
|
+
export const noTaskSignalDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
|
|
327
|
+
events
|
|
328
|
+
.filter(isNoTaskSignal)
|
|
329
|
+
.map(({ lane }) => `${terminalSafe(lane)} ${NO_TASK_SIGNAL_EVENT}: ${texts.noTaskSignalDuty}`)
|
|
330
|
+
|
|
299
331
|
const isApproval = (event: WatchEvent): event is ApprovalEvent => event.kind === WATCH_EVENT.approval
|
|
300
332
|
|
|
301
333
|
/**
|
|
@@ -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
|
+
}
|