jackherd 0.0.1 → 1.0.1
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 +65 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1122 -3
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +63 -5
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { kitUpdatedLine, pendingKitUpdate } from '../version/installed-version'
|
|
2
|
+
import type { StartedKit } from '../version/started-kit'
|
|
3
|
+
|
|
4
|
+
type Effects = { saveState: () => void; log: (line: string) => void; skillLine: () => string }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ends the watcher when the kit on disk is another version than the one it started with: saves the state
|
|
8
|
+
* as any stop does, then prints the line that wakes the coordinator and the line about the orchestrate
|
|
9
|
+
* skill. True when the watcher should end.
|
|
10
|
+
*/
|
|
11
|
+
export const endedByKitUpdate = (kit: StartedKit | null, { saveState, log, skillLine }: Effects): boolean => {
|
|
12
|
+
const update = pendingKitUpdate(kit)
|
|
13
|
+
if (!update) return false
|
|
14
|
+
saveState()
|
|
15
|
+
log(kitUpdatedLine(update))
|
|
16
|
+
log(skillLine())
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ContextLevel, ContextReading } from '../context'
|
|
2
|
+
import { isContextLevel, levelStep } from './coordinator-fill'
|
|
3
|
+
import { isRecord } from './json-guards'
|
|
4
|
+
|
|
5
|
+
export const LANE_FILL_EVENT = 'lane'
|
|
6
|
+
|
|
7
|
+
/** Lane → the context level last read for its agent; a lane without an entry counts as `ok`. */
|
|
8
|
+
export type LaneLevels = Record<string, ContextLevel>
|
|
9
|
+
|
|
10
|
+
/** Lane → its agent's fill as one read found it, null when there was no value (no agent, no log, no answer). */
|
|
11
|
+
export type LaneReadings = Record<string, ContextReading | null>
|
|
12
|
+
|
|
13
|
+
export type LaneFillEvent = { kind: typeof LANE_FILL_EVENT; lane: string; level: ContextLevel; share: number }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One event per lane whose level rose above the stored one, as for the coordinator (`levelStep`). A lane seen
|
|
17
|
+
* for the first time counts as `ok`: the fill is a condition, so one already above a threshold reports once.
|
|
18
|
+
*/
|
|
19
|
+
export const laneFillEvents = (previous: LaneLevels, readings: LaneReadings): LaneFillEvent[] =>
|
|
20
|
+
Object.entries(readings).flatMap(([lane, reading]) =>
|
|
21
|
+
reading && levelStep(previous[lane], reading.level).fire
|
|
22
|
+
? [{ kind: LANE_FILL_EVENT, lane, level: reading.level, share: reading.share }]
|
|
23
|
+
: [],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
/** The levels after a read: each watched lane at its current level, its stored one without a value; others dropped. */
|
|
27
|
+
export const recordLaneLevels = (previous: LaneLevels, readings: LaneReadings, lanes: readonly string[]): LaneLevels =>
|
|
28
|
+
Object.fromEntries(
|
|
29
|
+
lanes.flatMap((lane) => {
|
|
30
|
+
const level = levelStep(previous[lane], readings[lane]?.level ?? null).level
|
|
31
|
+
return level === null ? [] : [[lane, level]]
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
export const isLaneLevels = (value: unknown): value is LaneLevels =>
|
|
36
|
+
isRecord(value) && Object.values(value).every(isContextLevel)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import { AGENT_STATUS } from './lane-watch'
|
|
3
|
+
|
|
4
|
+
/** A lane's report file as the watcher found it: its path, when it was last written, its text. */
|
|
5
|
+
export type LaneReportFile = { path: string; mtimeMs: number; text: string }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the watcher knows when a lane stops: `reportedMtime` is the change time of the report file it showed last
|
|
9
|
+
* for this lane, null when it never showed one.
|
|
10
|
+
*/
|
|
11
|
+
export type LaneStop = { status: string; screen: string; report: LaneReportFile | null; reportedMtime: number | null }
|
|
12
|
+
|
|
13
|
+
/** The lines printed under `=== <lane>: <status>` and the report change time to remember for the lane. */
|
|
14
|
+
export type LaneMessage = { lines: string[]; reportedMtime: number | null }
|
|
15
|
+
|
|
16
|
+
const SCREEN_LINES = 30
|
|
17
|
+
const SCREEN_LINE_LENGTH = 220
|
|
18
|
+
/** How far up from the bottom the input box's rule may sit; a rule higher up belongs to the lane's text. */
|
|
19
|
+
const INPUT_BOX_DEPTH = 15
|
|
20
|
+
/** A line of box-drawing rule only, as Claude Code draws around its input box. */
|
|
21
|
+
const INPUT_BOX_RULE = /^[─▔]{20,}$/
|
|
22
|
+
const REPORT_LINES = 15
|
|
23
|
+
const REPORT_CHARACTERS = 1500
|
|
24
|
+
|
|
25
|
+
/** The last `SCREEN_LINES` lines, each made safe and cut. */
|
|
26
|
+
const lastScreenLines = (lines: readonly string[]): string =>
|
|
27
|
+
lines
|
|
28
|
+
.slice(-SCREEN_LINES)
|
|
29
|
+
.map((line) => terminalSafe(line).slice(0, SCREEN_LINE_LENGTH))
|
|
30
|
+
.join('\n')
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The screen without the input box and what follows it (the status bar, the mode line, the agent list): everything
|
|
34
|
+
* from the first rule line in the bottom `INPUT_BOX_DEPTH` lines on. Without such a line the screen stays whole. The
|
|
35
|
+
* last `SCREEN_LINES` lines above are kept, each made safe and cut.
|
|
36
|
+
*/
|
|
37
|
+
export const cutScreen = (screen: string): string => {
|
|
38
|
+
const lines = screen.split('\n')
|
|
39
|
+
const ruleIndex = lines.findIndex(
|
|
40
|
+
(line, index) => index >= lines.length - INPUT_BOX_DEPTH && INPUT_BOX_RULE.test(line.trim()),
|
|
41
|
+
)
|
|
42
|
+
return lastScreenLines(ruleIndex === -1 ? lines : lines.slice(0, ruleIndex))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The head of a report file: its first lines, made safe, within a character budget. */
|
|
46
|
+
export const reportExcerpt = (text: string): string =>
|
|
47
|
+
text.split('\n').slice(0, REPORT_LINES).map(terminalSafe).join('\n').slice(0, REPORT_CHARACTERS)
|
|
48
|
+
|
|
49
|
+
/** What `reportExcerpt` leaves out, in lines or else characters; null when the excerpt is the whole report. */
|
|
50
|
+
const reportRest = (text: string): string | null => {
|
|
51
|
+
const lines = text.split('\n')
|
|
52
|
+
if (lines.length > REPORT_LINES) return `+${lines.length - REPORT_LINES} lines`
|
|
53
|
+
const shown = lines.map(terminalSafe).join('\n').length
|
|
54
|
+
return shown > REPORT_CHARACTERS ? `+${shown - REPORT_CHARACTERS} characters` : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const isNewReport = (report: LaneReportFile | null, reportedMtime: number | null): report is LaneReportFile =>
|
|
58
|
+
report !== null && (reportedMtime === null || report.mtimeMs > reportedMtime)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a stopped lane shows the coordinator: a blocked lane its whole screen, since the dialog is the news; else a
|
|
62
|
+
* report the lane wrote since the last one shown (its path and head), else the screen without the input box.
|
|
63
|
+
*/
|
|
64
|
+
export const laneMessage = ({ status, screen, report, reportedMtime }: LaneStop): LaneMessage => {
|
|
65
|
+
if (status === AGENT_STATUS.blocked) return { lines: [lastScreenLines(screen.split('\n'))], reportedMtime }
|
|
66
|
+
if (isNewReport(report, reportedMtime)) {
|
|
67
|
+
const path = terminalSafe(report.path)
|
|
68
|
+
const rest = reportRest(report.text)
|
|
69
|
+
const more = rest === null ? [] : [`… ${rest}, read ${path}`]
|
|
70
|
+
return { lines: [`report ${path}`, reportExcerpt(report.text), ...more], reportedMtime: report.mtimeMs }
|
|
71
|
+
}
|
|
72
|
+
return { lines: [cutScreen(screen)], reportedMtime }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The lines a report remembers for the next comparison: the lane lines, then the lines after the coordinator's. */
|
|
76
|
+
export const rememberedStatusLines = (laneLines: readonly string[], otherLines: readonly string[]): string[] => [
|
|
77
|
+
...laneLines,
|
|
78
|
+
...otherLines,
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The status block of a report: whole when a lane line or a line after the coordinator's (`otherLines`) differs from
|
|
83
|
+
* `previous` (`rememberedStatusLines` of the last report) or nothing was reported before (null, which a heartbeat
|
|
84
|
+
* report passes to show it whole), else one line that says so, with the coordinator's line, which changes with every
|
|
85
|
+
* read.
|
|
86
|
+
*/
|
|
87
|
+
export const statusBlock = (
|
|
88
|
+
laneLines: readonly string[],
|
|
89
|
+
coordinatorLine: string,
|
|
90
|
+
previous: readonly string[] | null,
|
|
91
|
+
otherLines: readonly string[],
|
|
92
|
+
): string[] => {
|
|
93
|
+
const compared = rememberedStatusLines(laneLines, otherLines)
|
|
94
|
+
const unchanged =
|
|
95
|
+
previous !== null && previous.length === compared.length && previous.every((line, i) => line === compared[i])
|
|
96
|
+
return unchanged
|
|
97
|
+
? [`status: unchanged · ${coordinatorLine}`]
|
|
98
|
+
: ['status:', ...laneLines, coordinatorLine, ...otherLines]
|
|
99
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { dirname } from 'node:path'
|
|
2
|
+
import type { LaneProgress, SprintLane } from '../plan-format'
|
|
3
|
+
import { boardRowsOf } from '../plans/board-rows'
|
|
4
|
+
import { baseReaderOf, gitStatusOf } from '../plans/git'
|
|
5
|
+
import { laneWorkOf, readPlans, textAtBase } from '../plans/lane-plans'
|
|
6
|
+
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
|
|
7
|
+
import { workedLaneProgress } from '../plans/worked-phases'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The progress the watcher reports for a lane, from the same reading as the plans view: the base ref's plans, the
|
|
11
|
+
* phases the lane works on from its worktree (also without a board row), so both name the same open phase.
|
|
12
|
+
*/
|
|
13
|
+
export const watchedLaneProgress = async (
|
|
14
|
+
root: string,
|
|
15
|
+
settings: PlansSettings,
|
|
16
|
+
boardFile: string,
|
|
17
|
+
lane: SprintLane,
|
|
18
|
+
): Promise<LaneProgress> => {
|
|
19
|
+
const baseRef = baseRefOf(settings)
|
|
20
|
+
const worktree = worktreeOf(root, settings, lane.name)
|
|
21
|
+
const sources = {
|
|
22
|
+
root,
|
|
23
|
+
settings,
|
|
24
|
+
base: baseReaderOf(root, baseRef),
|
|
25
|
+
sprintDirectory: dirname(boardRepoPathOf(root, settings, boardFile)),
|
|
26
|
+
}
|
|
27
|
+
const work = laneWorkOf(worktree, await gitStatusOf(worktree, baseRef))
|
|
28
|
+
const { plans } = await readPlans(lane, sources, work, [])
|
|
29
|
+
return workedLaneProgress(lane, plans)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The rows the checkout's board file (`checkoutText`) has beyond the board at the base ref, counted as the plans view
|
|
34
|
+
* adds them; 0 when the base ref lacks the board, which is then read from the checkout as a whole.
|
|
35
|
+
*/
|
|
36
|
+
export const boardCheckoutRows = (
|
|
37
|
+
root: string,
|
|
38
|
+
settings: PlansSettings,
|
|
39
|
+
boardFile: string,
|
|
40
|
+
checkoutText: string,
|
|
41
|
+
): number => {
|
|
42
|
+
const atBase = textAtBase(baseReaderOf(root, baseRefOf(settings)), boardRepoPathOf(root, settings, boardFile))
|
|
43
|
+
return atBase === null ? 0 : boardRowsOf(atBase, checkoutText, settings.planLanguage).rowsFromCheckout
|
|
44
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { closeSync, lstatSync, openSync, readSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
|
|
4
|
+
import type { LaneReportFile } from './lane-message'
|
|
5
|
+
|
|
6
|
+
/** A lane name that stays a single file name: no separator, no `..`. */
|
|
7
|
+
const SAFE_LANE_NAME = /^(?!\.\.?$)[A-Za-z0-9._-]+$/
|
|
8
|
+
/** The head of a report the watcher reads; it prints far less, and a huge file must not stall it. */
|
|
9
|
+
const READ_LIMIT_BYTES = 8192
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Where a lane writes its report, inside its worktree: `.claude/handover/<lane>-report.md`. Null for a lane name
|
|
13
|
+
* that could leave that directory: names come from board rows and herdr agents.
|
|
14
|
+
*/
|
|
15
|
+
export const laneReportPath = (worktree: string, lane: string): string | null =>
|
|
16
|
+
SAFE_LANE_NAME.test(lane) ? join(worktree, claudePath(HANDOVER_DIRECTORY), `${lane}-report.md`) : null
|
|
17
|
+
|
|
18
|
+
/** The change time of a regular file at `path`, not followed through a symlink; null for anything else. */
|
|
19
|
+
const regularFileMtime = (path: string): number | null => {
|
|
20
|
+
try {
|
|
21
|
+
const stats = lstatSync(path)
|
|
22
|
+
return stats.isFile() ? stats.mtimeMs : null
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const readHead = (path: string): string => {
|
|
29
|
+
const descriptor = openSync(path, 'r')
|
|
30
|
+
try {
|
|
31
|
+
const buffer = Buffer.alloc(READ_LIMIT_BYTES)
|
|
32
|
+
return buffer.subarray(0, readSync(descriptor, buffer, 0, READ_LIMIT_BYTES, 0)).toString('utf8')
|
|
33
|
+
} finally {
|
|
34
|
+
closeSync(descriptor)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The change time of the report file; null when there is none or it is no regular file. */
|
|
39
|
+
export const reportMtime = (path: string | null): number | null => (path === null ? null : regularFileMtime(path))
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The report file with its change time and the first `READ_LIMIT_BYTES` of its text; null when it is missing,
|
|
43
|
+
* unreadable, or no regular file (a symlink, a directory, a FIFO).
|
|
44
|
+
*/
|
|
45
|
+
export const readLaneReport = (path: string | null): LaneReportFile | null => {
|
|
46
|
+
const mtimeMs = reportMtime(path)
|
|
47
|
+
if (path === null || mtimeMs === null) return null
|
|
48
|
+
try {
|
|
49
|
+
return { path, mtimeMs, text: readHead(path) }
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { CONTEXT_LEVEL, type ContextLevel, contextLabel } from '../context'
|
|
2
|
+
import { currentPhase, isTickedUnmerged, type LaneProgress } from '../plan-format'
|
|
3
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
+
import type { ChecksState } from '../plans/pull-requests'
|
|
5
|
+
import type { CoordinatorRead } from './coordinator-fill'
|
|
6
|
+
import { type FeedbackEvent, firstLine } from './feedback'
|
|
7
|
+
import type { LaneFillEvent } from './lane-fill'
|
|
8
|
+
import { AGENT_STATUS, type LaneSnapshot, NO_AGENT } from './lane-watch'
|
|
9
|
+
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
10
|
+
import { SOURCE_FIELD } from './source-fields'
|
|
11
|
+
import { type FieldChange, lineText, type SourceEvent } from './sources'
|
|
12
|
+
import type { WatchTexts } from './texts'
|
|
13
|
+
import {
|
|
14
|
+
type DeployEvent,
|
|
15
|
+
isPullRequestEvent,
|
|
16
|
+
type PullRequestEvent,
|
|
17
|
+
WATCH_EVENT,
|
|
18
|
+
type WatchEvent,
|
|
19
|
+
} from './watch-events'
|
|
20
|
+
import type { TakeOver } from './watcher-lock'
|
|
21
|
+
|
|
22
|
+
export type LaneReport = {
|
|
23
|
+
lane: string
|
|
24
|
+
status: string
|
|
25
|
+
branch: string | null
|
|
26
|
+
/** `feedback` counts the feedback reported since the pull request's last push. */
|
|
27
|
+
pullRequest: { number: number; checks: ChecksState; feedback: number } | null
|
|
28
|
+
progress: LaneProgress | null
|
|
29
|
+
/** The context level last read for the lane's agent; null without a value. */
|
|
30
|
+
context: ContextLevel | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The board as the coordinator line reminds of it: its term and the rows only the checkout's board file has. */
|
|
34
|
+
export type BoardReminder = { term: string; checkoutRows: number }
|
|
35
|
+
|
|
36
|
+
const UNKNOWN = 'unknown'
|
|
37
|
+
const REPORT_TEXT_LENGTH = 160
|
|
38
|
+
const FIELD_VALUE_LENGTH = 60
|
|
39
|
+
const EMPTY_VALUE = '(empty)'
|
|
40
|
+
const CHANGE_ARROW = '→'
|
|
41
|
+
const VALUE_ARROW = '->'
|
|
42
|
+
const FEEDBACK_ENTRY_LIMIT = 10
|
|
43
|
+
const REPORT_INDENT = ' '
|
|
44
|
+
const SHORT_SHA_LENGTH = 7
|
|
45
|
+
const NO_REMINDER: BoardReminder = { term: '', checkoutRows: 0 }
|
|
46
|
+
const OTHER_PULL_REQUEST_LIMIT = 5
|
|
47
|
+
|
|
48
|
+
const pullRequestSegment = (pullRequest: LaneReport['pullRequest'], texts: WatchTexts): string => {
|
|
49
|
+
if (!pullRequest) return texts.noPullRequest
|
|
50
|
+
const feedback = pullRequest.feedback ? ` · ${pullRequest.feedback} ${WATCH_EVENT.feedback}` : ''
|
|
51
|
+
return `PR #${pullRequest.number} ${pullRequest.checks}${feedback}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const phaseSegment = (progress: LaneProgress | null, texts: WatchTexts): string => {
|
|
55
|
+
if (!progress) return texts.noPlan
|
|
56
|
+
if (!progress.current) return progress.total > 0 ? texts.planFinished : texts.noPlan
|
|
57
|
+
const phase = currentPhase(progress)
|
|
58
|
+
if (!phase) return texts.noPlan
|
|
59
|
+
const title = phase.title ? ` ${terminalSafe(phase.title)}` : ''
|
|
60
|
+
if (isTickedUnmerged(phase)) return `${texts.phase} ${terminalSafe(phase.id)}${title} ${texts.tickedUnmerged}`
|
|
61
|
+
const startsAfter = progress.steps[progress.current.stepIndex]?.step.startsAfter
|
|
62
|
+
const after = startsAfter ? ` (${texts.startsAfter}: ${terminalSafe(startsAfter)})` : ''
|
|
63
|
+
return `${texts.openPhase} ${terminalSafe(phase.id)}${title}${after}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `context warn` or `context alert`; nothing at `ok` or without a value, so the line stays the same while the share moves. */
|
|
67
|
+
const contextSegment = (level: ContextLevel | null): string[] =>
|
|
68
|
+
level === null || level === CONTEXT_LEVEL.ok ? [] : [`context ${level}`]
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One line per lane, in the words of `texts`: `<lane> <status> [· context warn|alert] · <branch>|kein Branch ·
|
|
72
|
+
* PR #<n> <checks>|kein PR · offene Phase …|Phase … abgehakt, nicht gemergt|Plan fertig|kein Plan` in German.
|
|
73
|
+
*/
|
|
74
|
+
export const laneReportLine = (report: LaneReport, texts: WatchTexts): string =>
|
|
75
|
+
[
|
|
76
|
+
`${terminalSafe(report.lane)} ${terminalSafe(report.status)}`,
|
|
77
|
+
...contextSegment(report.context),
|
|
78
|
+
report.branch ? terminalSafe(report.branch) : texts.noBranch,
|
|
79
|
+
pullRequestSegment(report.pullRequest, texts),
|
|
80
|
+
phaseSegment(report.progress, texts),
|
|
81
|
+
].join(' · ')
|
|
82
|
+
|
|
83
|
+
/** The lane, or `branch <name>` for an own pull request without a lane, its name cut like other third-party text. */
|
|
84
|
+
const pullRequestOwner = (event: PullRequestEvent): string =>
|
|
85
|
+
event.lane === null ? `branch ${terminalSafe(event.branch).slice(0, REPORT_TEXT_LENGTH)}` : terminalSafe(event.lane)
|
|
86
|
+
|
|
87
|
+
/** The lane, `#<number>` for an own pull request without a lane, or `manual`. */
|
|
88
|
+
const deployOwner = (event: DeployEvent): string => {
|
|
89
|
+
if (event.lane !== null) return terminalSafe(event.lane)
|
|
90
|
+
return event.pullRequest === undefined ? 'manual' : `#${event.pullRequest}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `other PRs: #<n> passed · #<m> none`, in the words of `texts`: the open pull requests among `own` (the user's own without a
|
|
95
|
+
* lane), newest first, at most `OTHER_PULL_REQUEST_LIMIT` and `+<k>` for the rest; null when there is none.
|
|
96
|
+
*/
|
|
97
|
+
export const otherPullRequestsLine = (
|
|
98
|
+
pullRequests: readonly WatchedPullRequest[],
|
|
99
|
+
own: readonly number[],
|
|
100
|
+
texts: WatchTexts,
|
|
101
|
+
): string | null => {
|
|
102
|
+
const open = pullRequests
|
|
103
|
+
.filter(({ number, state }) => state === PULL_REQUEST_STATE.open && own.includes(number))
|
|
104
|
+
.sort((left, right) => right.number - left.number)
|
|
105
|
+
if (open.length === 0) return null
|
|
106
|
+
const shown = open.slice(0, OTHER_PULL_REQUEST_LIMIT).map(({ number, checks }) => `#${number} ${checks}`)
|
|
107
|
+
const more = open.length - OTHER_PULL_REQUEST_LIMIT
|
|
108
|
+
return `${texts.otherPullRequests}: ${[...shown, ...(more > 0 ? [`+${more}`] : [])].join(' · ')}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The header line of one event: `=== merged #<number> (lane)`, `=== checks failed #<number> (lane|branch <name>)`,
|
|
113
|
+
* `=== deploy success run 9 (push, lane|#<number>) target stage`, `=== heartbeat`, `=== gh unavailable: <reason>`,
|
|
114
|
+
* `=== coordinator warn 62 %`, `=== lane <name> alert 47 %`, `=== preview #<number> (lane) <url> · <sha7>`.
|
|
115
|
+
* `target` is the deploy environment read from the run, null when none was found.
|
|
116
|
+
*/
|
|
117
|
+
export const watchEventLine = (event: WatchEvent, target: string | null): string => {
|
|
118
|
+
if (event.kind === WATCH_EVENT.deploy) {
|
|
119
|
+
const by = deployOwner(event)
|
|
120
|
+
const outcome = terminalSafe(event.conclusion ?? UNKNOWN)
|
|
121
|
+
const where = terminalSafe(target ?? UNKNOWN)
|
|
122
|
+
return `=== deploy ${outcome} run ${event.runId} (${terminalSafe(event.trigger)}, ${by}) target ${where}`
|
|
123
|
+
}
|
|
124
|
+
if (event.kind === WATCH_EVENT.ghUnavailable) return `=== ${event.kind}: ${terminalSafe(event.reason)}`
|
|
125
|
+
if (event.kind === WATCH_EVENT.preview)
|
|
126
|
+
return `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)}) ${terminalSafe(event.url)} · ${terminalSafe(event.headCommit.slice(0, SHORT_SHA_LENGTH))}`
|
|
127
|
+
if (event.kind === WATCH_EVENT.laneFill)
|
|
128
|
+
return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.level} ${contextLabel(event.share)}`
|
|
129
|
+
if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
|
|
130
|
+
if (isPullRequestEvent(event)) return `=== ${event.kind} #${event.number} (${pullRequestOwner(event)})`
|
|
131
|
+
if ('number' in event) return `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)})`
|
|
132
|
+
return `=== ${event.kind}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The lines of a feedback event: `=== feedback #<number> (lane) <author> <label> · <k> new`, then one line per entry,
|
|
137
|
+
* `<path>:<line> <first line>` or `<first line>`, cut to a length and to `FEEDBACK_ENTRY_LIMIT` entries.
|
|
138
|
+
*/
|
|
139
|
+
export const feedbackEventLines = (event: FeedbackEvent): string[] => {
|
|
140
|
+
const header = `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)}) ${terminalSafe(event.author)} ${terminalSafe(event.label)} · ${event.entries.length} new`
|
|
141
|
+
const entries = event.entries.slice(0, FEEDBACK_ENTRY_LIMIT).map(({ place, text }) => {
|
|
142
|
+
const line = terminalSafe(place === null ? text : `${place} ${text}`).slice(0, REPORT_TEXT_LENGTH)
|
|
143
|
+
return `${REPORT_INDENT}${line}`
|
|
144
|
+
})
|
|
145
|
+
const more = event.entries.length - FEEDBACK_ENTRY_LIMIT
|
|
146
|
+
return [header, ...entries, ...(more > 0 ? [`${REPORT_INDENT}… ${more} more`] : [])]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A field value on one line (`lineText`), cut to 60, `(empty)` when empty; an arrow inside it becomes `->`, so a value
|
|
151
|
+
* cannot pass for a further change.
|
|
152
|
+
*/
|
|
153
|
+
const fieldValueText = (value: string): string => {
|
|
154
|
+
const text = lineText(value).replaceAll(CHANGE_ARROW, VALUE_ARROW).slice(0, FIELD_VALUE_LENGTH)
|
|
155
|
+
return text === '' ? EMPTY_VALUE : text
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `status In progress → Ready, assignee (empty) → Kim`. */
|
|
159
|
+
const fieldChangesText = (changes: readonly FieldChange[]): string =>
|
|
160
|
+
changes
|
|
161
|
+
.map(
|
|
162
|
+
({ field, from, to }) => `${terminalSafe(field)} ${fieldValueText(from)} ${CHANGE_ARROW} ${fieldValueText(to)}`,
|
|
163
|
+
)
|
|
164
|
+
.join(', ')
|
|
165
|
+
|
|
166
|
+
/** A field change shows the body only when the body is one of the changed fields. */
|
|
167
|
+
const showsBody = (changes: readonly FieldChange[] | undefined): boolean =>
|
|
168
|
+
changes === undefined || changes.some(({ field }) => field === SOURCE_FIELD.body)
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The lines of a source event: `=== source <name> [(lane)] <id> <title> · new|updated`, with `reportOn`
|
|
172
|
+
* `· updated: <field> <old> → <new>, …` (no body line unless the body changed), then the first line of the body
|
|
173
|
+
* with text that is no Markdown heading (`firstLine`), cut to a length, and the checked URL, each only when there is one; a failure is
|
|
174
|
+
* `=== source <name> [(lane)] failed: <reason>`, the entries past the report limit `=== source <name> [(lane)] +<n> more`. Everything but the reason comes from the external service.
|
|
175
|
+
*/
|
|
176
|
+
export const sourceEventLines = (event: SourceEvent): string[] => {
|
|
177
|
+
const lane = event.lane === null ? '' : ` (${terminalSafe(event.lane)})`
|
|
178
|
+
const head = `=== ${event.kind} ${terminalSafe(event.name)}${lane}`
|
|
179
|
+
if ('failure' in event) return [`${head} failed: ${event.failure}`]
|
|
180
|
+
if ('more' in event) return [`${head} +${event.more} more`]
|
|
181
|
+
const id = terminalSafe(event.id).slice(0, REPORT_TEXT_LENGTH)
|
|
182
|
+
const title = terminalSafe(event.title).slice(0, REPORT_TEXT_LENGTH)
|
|
183
|
+
const body = showsBody(event.changes) ? terminalSafe(firstLine(event.body)).slice(0, REPORT_TEXT_LENGTH) : ''
|
|
184
|
+
const changes = event.changes ? `: ${fieldChangesText(event.changes)}` : ''
|
|
185
|
+
return [
|
|
186
|
+
`${head} ${id}${title ? ` ${title}` : ''} · ${event.change}${changes}`,
|
|
187
|
+
...(body ? [`${REPORT_INDENT}${body}`] : []),
|
|
188
|
+
...(event.url ? [`${REPORT_INDENT}${terminalSafe(event.url)}`] : []),
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The status line while a source fails; `since` is the start of the streak as a clock time. */
|
|
193
|
+
export const sourceFailingStatusLine = (name: string, since: string): string =>
|
|
194
|
+
`${WATCH_EVENT.source} ${terminalSafe(name)} failing since ${since}`
|
|
195
|
+
|
|
196
|
+
const coordinatorValue = (name: string | null, read: CoordinatorRead | null, texts: WatchTexts): string => {
|
|
197
|
+
if (name === null) return `coordinator ${texts.noName}`
|
|
198
|
+
if (read === null) return `coordinator ${terminalSafe(name)} ${texts.noValue}`
|
|
199
|
+
const value =
|
|
200
|
+
'reading' in read
|
|
201
|
+
? `${contextLabel(read.reading.share)} ${read.reading.level}${read.note ? ` (${terminalSafe(read.note)})` : ''}`
|
|
202
|
+
: `${texts.noValue} (${terminalSafe(read.reason)})`
|
|
203
|
+
return `coordinator ${terminalSafe(name)} ${value}`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `coordinator <name> 62 % warn`, `coordinator <name> kein Wert (<reason>)` or `coordinator kein Name` in German;
|
|
208
|
+
* while the board has rows only in the checkout, `· board: 2 rows only in the checkout` follows.
|
|
209
|
+
*/
|
|
210
|
+
export const coordinatorStatusLine = (
|
|
211
|
+
name: string | null,
|
|
212
|
+
read: CoordinatorRead | null,
|
|
213
|
+
texts: WatchTexts,
|
|
214
|
+
board: BoardReminder = NO_REMINDER,
|
|
215
|
+
): string => {
|
|
216
|
+
const line = coordinatorValue(name, read, texts)
|
|
217
|
+
return board.checkoutRows > 0 ? `${line} · ${texts.checkoutRows(terminalSafe(board.term), board.checkoutRows)}` : line
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The status line while `gh` is unavailable; `since` is the start of the outage as a clock time. */
|
|
221
|
+
export const ghUnavailableStatusLine = (since: string): string => `${WATCH_EVENT.ghUnavailable} since ${since}`
|
|
222
|
+
|
|
223
|
+
type MergeEvent = PullRequestEvent & { kind: typeof WATCH_EVENT.merged }
|
|
224
|
+
|
|
225
|
+
const isMerge = (event: WatchEvent): event is MergeEvent => event.kind === WATCH_EVENT.merged
|
|
226
|
+
|
|
227
|
+
const mergeDuty = (merged: MergeEvent, lane: string, texts: WatchTexts): string => {
|
|
228
|
+
const duty = lane === merged.lane ? texts.mergedLaneDuty : texts.otherLaneDuty
|
|
229
|
+
return `${texts.afterMerge(merged.number)}: ${terminalSafe(lane)} ${duty}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* What a merge asks of every watched lane (`orchestrate` § 6), one line per lane and merge: the
|
|
234
|
+
* merged lane goes on or closes, every other lane rebases and goes on or waits with a reason. The
|
|
235
|
+
* watcher only names the duty; the coordinator decides and prompts the lanes.
|
|
236
|
+
*/
|
|
237
|
+
export const mergeDutyLines = (events: readonly WatchEvent[], lanes: readonly string[], texts: WatchTexts): string[] =>
|
|
238
|
+
events.filter(isMerge).flatMap((merged) => lanes.map((lane) => mergeDuty(merged, lane, texts)))
|
|
239
|
+
|
|
240
|
+
const isLaneAlert = (event: WatchEvent): event is LaneFillEvent =>
|
|
241
|
+
event.kind === WATCH_EVENT.laneFill && event.level === CONTEXT_LEVEL.alert
|
|
242
|
+
|
|
243
|
+
/** What a lane whose context reached `alert` asks of the coordinator (`orchestrate` § 8): one line per such lane. */
|
|
244
|
+
export const laneFillDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
|
|
245
|
+
events
|
|
246
|
+
.filter(isLaneAlert)
|
|
247
|
+
.map(({ lane }) => `${terminalSafe(lane)} context ${CONTEXT_LEVEL.alert}: ${texts.laneAlertDuty}`)
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What a blocked lane asks of the coordinator (`orchestrate` § 4), one line per stopped lane that
|
|
251
|
+
* sits in `blocked`: vet the question, answer it in the lane or ask the user, never hand the pane
|
|
252
|
+
* over. Lanes that stopped as `done` or `idle`, or left the listing, get none.
|
|
253
|
+
*/
|
|
254
|
+
export const blockedDutyLines = (stopped: readonly string[], current: LaneSnapshot, texts: WatchTexts): string[] =>
|
|
255
|
+
stopped
|
|
256
|
+
.filter((lane) => current[lane]?.status === AGENT_STATUS.blocked)
|
|
257
|
+
.map((lane) => `${terminalSafe(lane)} ${AGENT_STATUS.blocked}: ${texts.blockedDuty}`)
|
|
258
|
+
|
|
259
|
+
const takeOverNote = ({ replaced, stillRunning }: TakeOver): string => {
|
|
260
|
+
if (stillRunning !== null) return ` (watcher ${stillRunning} did not stop)`
|
|
261
|
+
return replaced === null ? '' : ` (replaced watcher ${replaced})`
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The first line of a run: `armed: <lane>=<state> … board <path>`, with a note when an older watcher was replaced or did not stop. */
|
|
265
|
+
export const armLine = (lanes: readonly string[], armed: LaneSnapshot, takeOver: TakeOver, board: string): string => {
|
|
266
|
+
const states =
|
|
267
|
+
lanes.map((lane) => `${terminalSafe(lane)}=${terminalSafe(armed[lane]?.status ?? NO_AGENT)}`).join(' ') ||
|
|
268
|
+
'no lanes'
|
|
269
|
+
return `armed: ${states}${takeOverNote(takeOver)} board ${terminalSafe(board)}`
|
|
270
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { WatchedPullRequest } from './pull-requests'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Branches in one search. GitHub documents a limit of 256 characters for a search and counts qualifiers
|
|
5
|
+
* differently from what was measured (16 branches, 506 characters, accepted). The cut is by count, not by length:
|
|
6
|
+
* ten names of 55 characters make a longer text than was ever measured.
|
|
7
|
+
*/
|
|
8
|
+
export const BRANCHES_PER_SEARCH = 10
|
|
9
|
+
|
|
10
|
+
/** Characters of a branch name that `head:` takes literally; anything else is left out of the search. */
|
|
11
|
+
const SEARCHABLE_BRANCH = /^[A-Za-z0-9._/-]+$/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The branches worth a search: no base branch (every merged lane ends there), no duplicates, no name the search
|
|
15
|
+
* syntax could misread (quotes, colons, brackets; `head:` with such a name was never seen to match). Such a lane
|
|
16
|
+
* keeps its merge detection from the light list and only goes without a check status.
|
|
17
|
+
*/
|
|
18
|
+
export const searchableBranches = (branches: readonly string[], baseBranch: string): string[] =>
|
|
19
|
+
[...new Set(branches)].filter((branch) => branch !== baseBranch && SEARCHABLE_BRANCH.test(branch)).sort()
|
|
20
|
+
|
|
21
|
+
/** The branches cut into batches of at most `BRANCHES_PER_SEARCH`. */
|
|
22
|
+
export const branchBatches = (branches: readonly string[]): string[][] =>
|
|
23
|
+
Array.from({ length: Math.ceil(branches.length / BRANCHES_PER_SEARCH) }, (_, index) =>
|
|
24
|
+
branches.slice(index * BRANCHES_PER_SEARCH, (index + 1) * BRANCHES_PER_SEARCH),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
/** `head:a head:b`: GitHub combines several `head:` qualifiers with or. */
|
|
28
|
+
export const searchTextOf = (branches: readonly string[]): string =>
|
|
29
|
+
branches.map((branch) => `head:${branch}`).join(' ')
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The pull requests whose branch is one of `branches`, exactly. `head:` matches a prefix and ignores case, so
|
|
33
|
+
* `head:fix/order-opening` also finds `fix/order-opening-hours`; a pull request of a foreign branch must not pass.
|
|
34
|
+
*/
|
|
35
|
+
export const onBranches = (
|
|
36
|
+
pullRequests: readonly WatchedPullRequest[],
|
|
37
|
+
branches: readonly string[],
|
|
38
|
+
): WatchedPullRequest[] => {
|
|
39
|
+
const wanted = new Set(branches)
|
|
40
|
+
return pullRequests.filter((pullRequest) => wanted.has(pullRequest.branch))
|
|
41
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { fetchBase } from '../plans/fetch'
|
|
3
|
+
import { BASE_REMOTE } from '../plans/settings'
|
|
4
|
+
import { baseFetchNeeded, remoteTipOf } from './base-tip'
|
|
5
|
+
|
|
6
|
+
const GIT_ASYNC_TIMEOUT_MS = 30_000
|
|
7
|
+
|
|
8
|
+
/** Runs git without blocking, killed after the timeout; its stdout, or null when it failed. Never rejects. */
|
|
9
|
+
const gitAsync = async (cwd: string, args: string[]): Promise<string | null> => {
|
|
10
|
+
try {
|
|
11
|
+
const child = Bun.spawn(['git', '-C', cwd, ...args], {
|
|
12
|
+
stdout: 'pipe',
|
|
13
|
+
stderr: 'ignore',
|
|
14
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
15
|
+
})
|
|
16
|
+
const timer = setTimeout(() => child.kill(), GIT_ASYNC_TIMEOUT_MS)
|
|
17
|
+
const [stdout, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited])
|
|
18
|
+
clearTimeout(timer)
|
|
19
|
+
return exitCode === 0 ? stdout : null
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const git = (cwd: string, args: string[]): string | null => {
|
|
26
|
+
const result = Bun.spawnSync(['git', '-C', cwd, ...args], { stdout: 'pipe', stderr: 'pipe' })
|
|
27
|
+
return result.exitCode === 0 ? result.stdout.toString().trim() : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The branch checked out in a worktree, or null for a missing worktree or a detached head. */
|
|
31
|
+
export const branchOf = (worktree: string): string | null =>
|
|
32
|
+
existsSync(worktree) ? git(worktree, ['branch', '--show-current']) || null : null
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetches the base branch when the remote moved past `origin/<branch>`, so every reader of that ref
|
|
36
|
+
* (the plans view watches `FETCH_HEAD`) sees a merge as soon as the watcher does. Asks the remote
|
|
37
|
+
* first, so an unchanged base costs no fetch; a failure leaves the ref as it is.
|
|
38
|
+
*/
|
|
39
|
+
export const fetchBaseWhenMoved = async (root: string, branch: string): Promise<void> => {
|
|
40
|
+
const [remote, local] = await Promise.all([
|
|
41
|
+
gitAsync(root, ['ls-remote', BASE_REMOTE, `refs/heads/${branch}`]),
|
|
42
|
+
gitAsync(root, ['rev-parse', '--verify', '--quiet', `${BASE_REMOTE}/${branch}^{commit}`]),
|
|
43
|
+
])
|
|
44
|
+
if (!baseFetchNeeded(remote === null ? null : remoteTipOf(remote, branch), local?.trim() || null)) return
|
|
45
|
+
await fetchBase(root, BASE_REMOTE, branch)
|
|
46
|
+
}
|