jackherd 1.9.0 → 1.11.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 +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- 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/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 -187
- 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 +57 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- 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 +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
- 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 +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +5 -1
- package/schemas/jackherd.schema.json +139 -5
- 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 +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- 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/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -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/fetch.ts +26 -3
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- 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-summary.ts +253 -45
- package/src/plans/layout-phase.ts +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/phase-ids.ts +8 -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/planned.ts +22 -0
- package/src/plans/pull-requests.ts +17 -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/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- 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 +66 -17
- package/src/plans/subagents.ts +64 -6
- 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 +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -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/claude-read.ts +27 -10
- package/src/terminal/text.ts +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- 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-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +47 -4
- package/src/watch/stream-output.ts +4 -3
- 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 +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- 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/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- 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,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/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
|
}
|
|
@@ -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,35 +2,57 @@ 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
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;
|
|
36
58
|
* null without a row that links a plan or when neither has the file.
|
|
@@ -41,15 +63,25 @@ export const watchedLanePlanText = (
|
|
|
41
63
|
boardFile: string,
|
|
42
64
|
lane: SprintLane,
|
|
43
65
|
): string | null => {
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
const path = resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href)
|
|
66
|
+
const path = lanePlanPath(root, settings, boardFile, lane)
|
|
67
|
+
if (path === null) return null
|
|
47
68
|
const worktreeFile = join(worktreeOf(root, settings, lane.name), path)
|
|
48
69
|
return existsSync(worktreeFile)
|
|
49
70
|
? readFileSync(worktreeFile, 'utf8')
|
|
50
71
|
: textAtBase(baseReaderOf(root, baseRefOf(settings)), path)
|
|
51
72
|
}
|
|
52
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
|
+
|
|
53
85
|
/**
|
|
54
86
|
* The phases of a lane's plan that can run together, by the plan's dependency table and ticks; empty without a plan,
|
|
55
87
|
* without a table or without two such phases.
|
|
@@ -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,
|
|
@@ -40,6 +42,7 @@ export type LaneReport = {
|
|
|
40
42
|
/** The board as the coordinator line reminds of it: its term and the rows only the checkout's board file has. */
|
|
41
43
|
export type BoardReminder = { term: string; checkoutRows: number }
|
|
42
44
|
|
|
45
|
+
const RESTING_STATUSES: ReadonlySet<string> = new Set([AGENT_STATUS.done, AGENT_STATUS.idle])
|
|
43
46
|
const UNKNOWN = 'unknown'
|
|
44
47
|
const REPORT_TEXT_LENGTH = 160
|
|
45
48
|
const FIELD_VALUE_LENGTH = 60
|
|
@@ -78,17 +81,33 @@ const contextSegment = (level: ContextLevel | null): string[] =>
|
|
|
78
81
|
|
|
79
82
|
/**
|
|
80
83
|
* One line per lane, in the words of `texts`: `<lane> <status> [· context warn|alert] [· waits on: <question>, <n> open questions] · <branch>|no branch ·
|
|
81
|
-
* PR #<n> <checks>|no PR · open phase …|phase … ticked, not merged|plan finished|no plan`.
|
|
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.
|
|
82
87
|
*/
|
|
83
|
-
export const laneReportLine = (report: LaneReport, texts: WatchTexts): string =>
|
|
84
|
-
|
|
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 [
|
|
85
94
|
`${terminalSafe(report.lane)} ${terminalSafe(report.status)}`,
|
|
86
95
|
...contextSegment(report.context),
|
|
87
96
|
...(report.rest ? [terminalSafe(report.rest)] : []),
|
|
88
97
|
report.branch ? terminalSafe(report.branch) : texts.noBranch,
|
|
89
98
|
pullRequestSegment(report.pullRequest, texts),
|
|
90
|
-
|
|
99
|
+
waitsForClosing ? closing : phase,
|
|
91
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
|
+
}
|
|
92
111
|
|
|
93
112
|
/** The lane, or `branch <name>` for an own pull request without a lane, its name cut like other third-party text. */
|
|
94
113
|
const pullRequestOwner = (event: PullRequestEvent): string =>
|
|
@@ -154,6 +173,7 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
|
|
|
154
173
|
if (event.kind === WATCH_EVENT.laneParallel)
|
|
155
174
|
return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
|
|
156
175
|
if (event.kind === WATCH_EVENT.taskParallel) return `=== ${terminalSafe(event.line)}`
|
|
176
|
+
if (event.kind === WATCH_EVENT.noTaskSignal) return `=== ${terminalSafe(noTaskSignalLine(event))}`
|
|
157
177
|
if (event.kind === WATCH_EVENT.approval)
|
|
158
178
|
return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
|
|
159
179
|
if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
|
|
@@ -300,6 +320,14 @@ export const parallelDutyLines = (events: readonly WatchEvent[], texts: WatchTex
|
|
|
300
320
|
.filter(isLaneParallel)
|
|
301
321
|
.map(({ lane, phases }) => `${terminalSafe(lane)} parallel: ${texts.parallelDuty(phasesText(phases))}`)
|
|
302
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
|
+
|
|
303
331
|
const isApproval = (event: WatchEvent): event is ApprovalEvent => event.kind === WATCH_EVENT.approval
|
|
304
332
|
|
|
305
333
|
/**
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { fetchBase } from '../plans/fetch'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { fetchBase, fetchPlanHeads } from '../plans/fetch'
|
|
3
|
+
import { commitOf } from '../plans/git'
|
|
4
|
+
import { BASE_REMOTE, baseRefOf } from '../plans/settings'
|
|
5
|
+
import { baseFetchNeeded, movedHeads, remoteTipOf } from './base-tip'
|
|
6
|
+
import { behindCountOf, type MainCheckoutRead } from './main-checkout'
|
|
7
|
+
import type { WatchedPullRequest } from './pull-requests'
|
|
5
8
|
|
|
6
9
|
const GIT_ASYNC_TIMEOUT_MS = 30_000
|
|
7
10
|
|
|
@@ -31,6 +34,20 @@ const git = (cwd: string, args: string[]): string | null => {
|
|
|
31
34
|
export const branchOf = (worktree: string): string | null =>
|
|
32
35
|
existsSync(worktree) ? git(worktree, ['branch', '--show-current']) || null : null
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The main checkout's branch and the commits of `origin/<base>` it lacks, as far as git can tell; read from local refs
|
|
39
|
+
* only. Null when git cannot resolve HEAD, so an unreadable checkout never reads as a detached head.
|
|
40
|
+
*/
|
|
41
|
+
export const readMainCheckout = (root: string, baseBranch: string): MainCheckoutRead | null => {
|
|
42
|
+
if (git(root, ['rev-parse', '--verify', '--quiet', 'HEAD']) === null) return null
|
|
43
|
+
const remoteBase = baseRefOf({ baseBranch })
|
|
44
|
+
return {
|
|
45
|
+
branch: branchOf(root),
|
|
46
|
+
behind: behindCountOf(git(root, ['rev-list', '--count', `HEAD..${remoteBase}`])),
|
|
47
|
+
remoteBase,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
/**
|
|
35
52
|
* Fetches the base branch when the remote moved past `origin/<branch>`, so every reader of that ref
|
|
36
53
|
* (the herd watches `FETCH_HEAD`) sees a merge as soon as the watcher does. Asks the remote
|
|
@@ -44,3 +61,14 @@ export const fetchBaseWhenMoved = async (root: string, branch: string): Promise<
|
|
|
44
61
|
if (!baseFetchNeeded(remote === null ? null : remoteTipOf(remote, branch), local?.trim() || null)) return
|
|
45
62
|
await fetchBase(root, BASE_REMOTE, branch)
|
|
46
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fetches the heads of the plan pull requests whose head commit moved past the herd's ref of it, in one call, so the
|
|
67
|
+
* watcher and the herd read the same ticks; an unchanged head costs no fetch, a failure leaves the refs as they are.
|
|
68
|
+
*/
|
|
69
|
+
export const fetchPlanHeadsWhenMoved = (root: string, pullRequests: readonly WatchedPullRequest[]): Promise<void> =>
|
|
70
|
+
fetchPlanHeads(
|
|
71
|
+
root,
|
|
72
|
+
BASE_REMOTE,
|
|
73
|
+
movedHeads(pullRequests, (ref) => commitOf(root, ref)),
|
|
74
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import type { WatchTexts } from './texts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What git says about the main checkout: the branch it has checked out (null for a detached head) and the commits of
|
|
6
|
+
* `remoteBase` (`origin/<base>`) it lacks.
|
|
7
|
+
*/
|
|
8
|
+
export type MainCheckoutRead = { branch: string | null; behind: number | null; remoteBase: string }
|
|
9
|
+
|
|
10
|
+
const COUNT = /^\d+$/
|
|
11
|
+
|
|
12
|
+
/** The count `git rev-list --count` printed; null when git failed or printed something else. */
|
|
13
|
+
export const behindCountOf = (revList: string | null): number | null => {
|
|
14
|
+
const count = revList?.trim() ?? ''
|
|
15
|
+
return COUNT.test(count) ? Number(count) : null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The duty line of the main checkout, in every report and heartbeat until it is resolved. With another branch or a
|
|
20
|
+
* detached head checked out: `main checkout on <branch>, not <base>`, since the main checkout is the coordinator's seat
|
|
21
|
+
* on the base and a fast-forward would land on the wrong branch. With the base checked out and behind the merges on
|
|
22
|
+
* the remote: `main checkout behind origin/<base> by <n> (git merge --ff-only origin/<base>)`. Null when caught up or
|
|
23
|
+
* unreadable; a `read` of null (git could not resolve the main checkout's HEAD) is unreadable, not a detached head.
|
|
24
|
+
*/
|
|
25
|
+
export const mainCheckoutDutyLine = (
|
|
26
|
+
read: MainCheckoutRead | null,
|
|
27
|
+
baseBranch: string,
|
|
28
|
+
texts: WatchTexts,
|
|
29
|
+
): string | null => {
|
|
30
|
+
if (read === null) return null
|
|
31
|
+
if (read.branch !== baseBranch) return terminalSafe(texts.mainCheckoutOffBase(read.branch, baseBranch))
|
|
32
|
+
if (read.behind === null || read.behind === 0) return null
|
|
33
|
+
return terminalSafe(texts.mainCheckoutBehind(read.remoteBase, read.behind))
|
|
34
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { dirname, join } from 'node:path'
|
|
2
|
+
import { isPrivateLevels, type LocationSystem, readPrivateText, systemLocation } from '../shared/private-location'
|
|
3
3
|
import {
|
|
4
4
|
sourceTicketsOf,
|
|
5
5
|
type TicketSource,
|
|
@@ -8,12 +8,15 @@ import {
|
|
|
8
8
|
watchBadgesOf,
|
|
9
9
|
watchHealthOf,
|
|
10
10
|
} from './plan-badges'
|
|
11
|
+
import { sourceStatusesOf } from './source-statuses'
|
|
11
12
|
import { parseWatchState, type WatchState } from './watch-events'
|
|
12
13
|
import { type CommandLineOf, isWatcherRunning, WATCHER_FILES, watcherDirectoryOf } from './watcher-lock'
|
|
13
14
|
|
|
14
15
|
const readState = (file: string): WatchState | null => {
|
|
16
|
+
const text = readPrivateText(file)
|
|
17
|
+
if (text === null) return null
|
|
15
18
|
try {
|
|
16
|
-
return parseWatchState(
|
|
19
|
+
return parseWatchState(text)
|
|
17
20
|
} catch {
|
|
18
21
|
return null
|
|
19
22
|
}
|
|
@@ -21,22 +24,32 @@ const readState = (file: string): WatchState | null => {
|
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* What the watcher of the repository at `root` gives the herd, read from its state file without a GitHub call:
|
|
24
|
-
* its badges, the tickets of `ticketSources`, and its health, so the view can say why there are none. A missing or
|
|
25
|
-
* damaged file shows no badges; the view goes on without them.
|
|
27
|
+
* its badges, the tickets of `ticketSources`, the status of the sources in `statusNames`, and its health, so the view can say why there are none. A missing or
|
|
28
|
+
* damaged file shows no badges; the view goes on without them. Nothing is read unless both levels of the watcher
|
|
29
|
+
* directory are private (`isPrivateLevels`), and only regular files, so a directory, a file or a FIFO someone else
|
|
30
|
+
* planted neither feeds the view nor blocks it.
|
|
26
31
|
*/
|
|
27
32
|
export const readWatchReading = (
|
|
28
33
|
root: string,
|
|
29
34
|
now: number,
|
|
30
35
|
ticketSources: readonly TicketSource[],
|
|
31
36
|
lookUp?: CommandLineOf,
|
|
37
|
+
system: LocationSystem = systemLocation(),
|
|
38
|
+
statusNames: readonly string[] = [],
|
|
32
39
|
): WatchReading => {
|
|
33
|
-
const directory = watcherDirectoryOf(root)
|
|
34
|
-
const
|
|
40
|
+
const directory = watcherDirectoryOf(root, system)
|
|
41
|
+
const isPrivate = isPrivateLevels(dirname(directory), directory, system)
|
|
42
|
+
const state = isPrivate ? readState(join(directory, WATCHER_FILES.state)) : null
|
|
35
43
|
const health = watchHealthOf(
|
|
36
44
|
state?.savedAt ?? null,
|
|
37
|
-
isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
|
|
45
|
+
isPrivate && isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
|
|
38
46
|
now,
|
|
39
47
|
)
|
|
40
48
|
if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health }
|
|
41
|
-
return {
|
|
49
|
+
return {
|
|
50
|
+
badges: watchBadgesOf(state, now),
|
|
51
|
+
tickets: sourceTicketsOf(state, ticketSources, now),
|
|
52
|
+
health,
|
|
53
|
+
...(statusNames.length > 0 ? { sources: sourceStatusesOf(state, statusNames, now) } : {}),
|
|
54
|
+
}
|
|
42
55
|
}
|
package/src/watch/plan-badges.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { sanitisedFailedTestPlaces, sanitisedFailedTests } from './failed-tests'
|
|
|
3
3
|
import { failedTestPlacesOf, failedTestsOf } from './failures'
|
|
4
4
|
import { previewUrlOf } from './previews'
|
|
5
5
|
import { PULL_REQUEST_STATE } from './pull-requests'
|
|
6
|
+
import type { SourceStatus } from './source-statuses'
|
|
6
7
|
import { lineText, type TicketRecord } from './sources'
|
|
7
8
|
import type { WatchState } from './watch-events'
|
|
8
9
|
|
|
@@ -45,7 +46,13 @@ export type TicketBadge = { id: string; url: string | null; status: string | nul
|
|
|
45
46
|
export type SourceTicket = TicketBadge & { lane: string | null }
|
|
46
47
|
|
|
47
48
|
/** The badges the view draws, the tickets of the marked sources, and why there may be none. */
|
|
48
|
-
export type WatchReading = {
|
|
49
|
+
export type WatchReading = {
|
|
50
|
+
badges: WatchBadges
|
|
51
|
+
tickets: SourceTicket[]
|
|
52
|
+
health: WatchHealth
|
|
53
|
+
/** The status of the sources the herd lists; absent when none was asked for. */
|
|
54
|
+
sources?: SourceStatus[]
|
|
55
|
+
}
|
|
49
56
|
|
|
50
57
|
/** Three missed GitHub reads of the watcher (one a minute): an older state is not shown (D96). */
|
|
51
58
|
export const STALE_AFTER_MS = 180_000
|
|
@@ -58,7 +65,8 @@ const MINUTE_MS = 60_000
|
|
|
58
65
|
const shownFailedTestsOf = (state: WatchState, number: string, headCommit: string): string[] =>
|
|
59
66
|
sanitisedFailedTests(failedTestsOf(state.failures, Number(number), headCommit))
|
|
60
67
|
|
|
61
|
-
const isFresh = (savedAt: number | null, now: number): boolean =>
|
|
68
|
+
export const isFresh = (savedAt: number | null, now: number): boolean =>
|
|
69
|
+
savedAt !== null && now - savedAt <= STALE_AFTER_MS
|
|
62
70
|
|
|
63
71
|
/**
|
|
64
72
|
* The health of a watcher from whether it runs and when it last saved its state. A watcher that runs but has not
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
checksOf,
|
|
5
5
|
type FailedCheck,
|
|
6
6
|
failedChecksOf,
|
|
7
|
+
filePathsOf,
|
|
7
8
|
type GhPullRequest,
|
|
8
9
|
isGhPullRequest,
|
|
9
10
|
} from '../plans/pull-requests'
|
|
@@ -25,6 +26,8 @@ export type WatchedPullRequest = {
|
|
|
25
26
|
failedRuns: FailedCheck[]
|
|
26
27
|
headCommit: string
|
|
27
28
|
mergeCommit: string | null
|
|
29
|
+
/** The paths it changes (at most 100), from the open list only; a plan among them makes its head a plan head. */
|
|
30
|
+
files?: string[]
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/** The rollup is optional: the light list has none. */
|
|
@@ -41,8 +44,8 @@ type GhWatchedPullRequest = Omit<GhPullRequest, 'statusCheckRollup'> & {
|
|
|
41
44
|
*/
|
|
42
45
|
export const WATCHED_PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,state,headRefOid,mergeCommit'
|
|
43
46
|
|
|
44
|
-
/** The fields of the list of open pull requests, which is the only one that asks for their checks. */
|
|
45
|
-
export const OPEN_PULL_REQUEST_FIELDS = `${WATCHED_PULL_REQUEST_FIELDS},statusCheckRollup`
|
|
47
|
+
/** The fields of the list of open pull requests, which is the only one that asks for their checks and their files. */
|
|
48
|
+
export const OPEN_PULL_REQUEST_FIELDS = `${WATCHED_PULL_REQUEST_FIELDS},statusCheckRollup,files`
|
|
46
49
|
|
|
47
50
|
const GH_PR_LIST = 'gh pr list'
|
|
48
51
|
|
|
@@ -79,6 +82,7 @@ const watchedPullRequestsOf = (
|
|
|
79
82
|
failedRuns: failedChecksOf(pullRequest.statusCheckRollup ?? [], pullRequest.url),
|
|
80
83
|
headCommit: pullRequest.headRefOid,
|
|
81
84
|
mergeCommit: pullRequest.mergeCommit?.oid ?? null,
|
|
85
|
+
...filePathsOf(pullRequest),
|
|
82
86
|
}))
|
|
83
87
|
|
|
84
88
|
const asIs = (entry: unknown): unknown => entry
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { claudePath, SKILLS_DIRECTORY } from '../claude/paths'
|
|
3
|
+
import { CONTEXT_LEVEL } from '../context'
|
|
4
|
+
import { APPOINTMENT_EVENT } from './appointments'
|
|
5
|
+
import { UPDATE_AVAILABLE_EVENT, WATCH_EVENT } from './watch-events'
|
|
6
|
+
|
|
7
|
+
/** The skill the watcher points the coordinator to, by its name without a prefix. */
|
|
8
|
+
export const ORCHESTRATE_SKILL_NAME = 'orchestrate'
|
|
9
|
+
|
|
10
|
+
/** The reference files of the orchestrate skill, relative to its directory. */
|
|
11
|
+
export const REFERENCE = {
|
|
12
|
+
events: 'references/events.md',
|
|
13
|
+
laneSession: 'references/lane-session.md',
|
|
14
|
+
coordinatorHandover: 'references/coordinator-handover.md',
|
|
15
|
+
closeLane: 'references/close-lane.md',
|
|
16
|
+
laneHandover: 'references/lane-handover.md',
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
export type Reference = (typeof REFERENCE)[keyof typeof REFERENCE]
|
|
20
|
+
|
|
21
|
+
/** A copy of the orchestrate skill: the directory as the session reads it, and where that is on disk. */
|
|
22
|
+
export type SkillCopy = { shown: string; path: string }
|
|
23
|
+
|
|
24
|
+
const SKILL_ENTRY = 'SKILL.md'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The rare events and the reference that holds their handling. The frequent ones (`blocked`, `done`/`idle`, `merged`,
|
|
28
|
+
* the checks, `heartbeat`, `approval`, the lanes' fill, the watcher's end, `kit updated`) stay in the skill and have
|
|
29
|
+
* no path, so their lines do not grow.
|
|
30
|
+
*/
|
|
31
|
+
const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
|
|
32
|
+
[WATCH_EVENT.feedback]: REFERENCE.events,
|
|
33
|
+
[WATCH_EVENT.preview]: REFERENCE.events,
|
|
34
|
+
[WATCH_EVENT.source]: REFERENCE.events,
|
|
35
|
+
[APPOINTMENT_EVENT.unreadable]: REFERENCE.events,
|
|
36
|
+
[WATCH_EVENT.laneParallel]: REFERENCE.events,
|
|
37
|
+
[WATCH_EVENT.taskParallel]: REFERENCE.events,
|
|
38
|
+
[WATCH_EVENT.noTaskSignal]: REFERENCE.events,
|
|
39
|
+
[UPDATE_AVAILABLE_EVENT]: REFERENCE.events,
|
|
40
|
+
[WATCH_EVENT.laneModel]: REFERENCE.laneSession,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const isCoordinatorHandoverLevel = (level: unknown): boolean =>
|
|
44
|
+
level === CONTEXT_LEVEL.warn || level === CONTEXT_LEVEL.alert
|
|
45
|
+
|
|
46
|
+
/** The reference to read for `event`; null for a frequent event, and for a coordinator still at `ok`. */
|
|
47
|
+
export const eventReference = (event: { kind: string; level?: unknown }): Reference | null => {
|
|
48
|
+
if (event.kind === WATCH_EVENT.coordinator)
|
|
49
|
+
return isCoordinatorHandoverLevel(event.level) ? REFERENCE.coordinatorHandover : null
|
|
50
|
+
return EVENT_REFERENCES[event.kind] ?? null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The places the orchestrate skill is installed, the project's first: `<root>/.claude/skills/<prefix>orchestrate`, and
|
|
55
|
+
* the global copy `~/.claude/skills/<globalPrefix>orchestrate` that `jh sync --global` writes (`toGlobalKit`). The
|
|
56
|
+
* project shows its path relative to the root the session runs in, the global one with `~`.
|
|
57
|
+
*/
|
|
58
|
+
export const skillCopiesFor = (
|
|
59
|
+
root: string,
|
|
60
|
+
home: string,
|
|
61
|
+
projectPrefix: string,
|
|
62
|
+
globalPrefix: string,
|
|
63
|
+
): SkillCopy[] => {
|
|
64
|
+
const project = claudePath(SKILLS_DIRECTORY, `${projectPrefix}${ORCHESTRATE_SKILL_NAME}`)
|
|
65
|
+
const global = claudePath(SKILLS_DIRECTORY, `${globalPrefix}${ORCHESTRATE_SKILL_NAME}`)
|
|
66
|
+
return [
|
|
67
|
+
{ shown: project, path: join(root, project) },
|
|
68
|
+
{ shown: `~/${global}`, path: join(home, global) },
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The directory of the first copy that holds the skill: the project's installation, else the global one. `exists`
|
|
74
|
+
* answers for a path on disk, so the choice stays a pure function.
|
|
75
|
+
*/
|
|
76
|
+
export const skillDirectoryFor = (copies: readonly SkillCopy[], exists: (path: string) => boolean): string | null =>
|
|
77
|
+
copies.find(({ path }) => exists(join(path, SKILL_ENTRY)))?.shown ?? null
|
|
78
|
+
|
|
79
|
+
/** ` · read <skill directory>/<reference>`, naming the skill when no copy of it was found. */
|
|
80
|
+
export const referenceHint = (reference: Reference, skillDirectory: string | null): string =>
|
|
81
|
+
skillDirectory === null
|
|
82
|
+
? ` · read ${ORCHESTRATE_SKILL_NAME} skill ${reference}`
|
|
83
|
+
: ` · read ${skillDirectory}/${reference}`
|
|
84
|
+
|
|
85
|
+
/** `lines` with `hint` on the first one, the header of a multi-line event. */
|
|
86
|
+
export const annotateFirstLine = (lines: readonly string[], hint: string): string[] =>
|
|
87
|
+
lines.map((line, index) => (index === 0 ? `${line}${hint}` : line))
|
|
@@ -8,8 +8,9 @@ const CODE_FENCE = /^\s*(```|~~~)/
|
|
|
8
8
|
const LIST_ITEM = /^(?:\d+[.)]|[-*])\s/
|
|
9
9
|
const CONTINUATION_LINE = /^\s/
|
|
10
10
|
const LIST_MARKER = /^(?:\d+[.)]|[-*])\s*/
|
|
11
|
-
/** What a section writes when it has nothing to ask: "Keine.", "None.", "Keine offenen Fragen.", a dash. */
|
|
12
|
-
const NO_QUESTIONS =
|
|
11
|
+
/** What a section writes when it has nothing to ask: "Keine.", "None.", "Keine offenen Fragen.", "Keine offenen.", a dash. */
|
|
12
|
+
const NO_QUESTIONS =
|
|
13
|
+
/^(?:(?:keine|none|no|nein)(?:\s+(?:(?:offen(?:en?)?|open)(?:\s+ones?)?|(?:offenen\s+)?(?:fragen?|questions?)))?\.?|n\/a|[—–])$/i
|
|
13
14
|
|
|
14
15
|
/** What the questions part of a message may take: whole up to these limits, else cut question by question. */
|
|
15
16
|
const QUESTION_LINES = 20
|
|
@@ -30,7 +31,7 @@ const headings = (lines: readonly string[]): { index: number; level: number; isQ
|
|
|
30
31
|
/** True when every line of the body, ignoring blank lines, a list marker and bold, says there is nothing to ask. */
|
|
31
32
|
const asksNothing = (body: readonly string[]): boolean =>
|
|
32
33
|
body
|
|
33
|
-
.map((line) => line.
|
|
34
|
+
.map((line) => line.replaceAll('**', '').trim().replace(LIST_MARKER, '').trim())
|
|
34
35
|
.filter((text) => text !== '')
|
|
35
36
|
.every((text) => NO_QUESTIONS.test(text))
|
|
36
37
|
|