jackherd 0.0.1 → 1.0.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 +61 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1121 -2
- 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,300 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import type { ContextReading } from '../context'
|
|
4
|
+
import { type AgentLogReader, contextOfAgent } from '../context/report'
|
|
5
|
+
import { agentLog } from '../context/session-log'
|
|
6
|
+
import {
|
|
7
|
+
type LaneProgress,
|
|
8
|
+
laneProgress,
|
|
9
|
+
readSprintDocument,
|
|
10
|
+
resolvePlanPath,
|
|
11
|
+
type SprintLane,
|
|
12
|
+
shortTitle,
|
|
13
|
+
sprintTitleOf,
|
|
14
|
+
} from '../plan-format'
|
|
15
|
+
import type { LaneAgent, LaneStatusSource } from '../terminal'
|
|
16
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
17
|
+
import type { WatchBadges, WatchHealth } from '../watch/plan-badges'
|
|
18
|
+
import { readWatchReading } from '../watch/plan-badges-read'
|
|
19
|
+
import { boardRowsOf } from './board-rows'
|
|
20
|
+
import { errorText } from './error-text'
|
|
21
|
+
import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
|
|
22
|
+
import type { PullRequestState } from './github'
|
|
23
|
+
import { type Fallbacks, laneWorkOf, readAtBase, readFallback, readPlans, textAtBase } from './lane-plans'
|
|
24
|
+
import { type LaneView, sortByUrgency } from './layout'
|
|
25
|
+
import { extractLinkChips } from './link-chips'
|
|
26
|
+
import type { Options } from './options'
|
|
27
|
+
import { currentPlanFirst, followingPlans, type PlannedPlan, plannedPlans } from './planned'
|
|
28
|
+
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
|
|
29
|
+
import { boardTexts } from './texts'
|
|
30
|
+
import { type UnlistedLane, unlistedLanes, unlistedSprintLane } from './unlisted-lanes'
|
|
31
|
+
import { withWorkedPhases } from './worked-phases'
|
|
32
|
+
|
|
33
|
+
/** Where the view reads from: the main checkout, the sprint document, the command line and the project settings. */
|
|
34
|
+
export type Source = {
|
|
35
|
+
root: string
|
|
36
|
+
sprintFile: string
|
|
37
|
+
options: Options
|
|
38
|
+
settings: PlansSettings
|
|
39
|
+
terminal: LaneStatusSource
|
|
40
|
+
/** The log lines of an agent's chat, null without one; defaults to reading them from `~/.claude/projects`. */
|
|
41
|
+
readAgentLog?: AgentLogReader
|
|
42
|
+
/** Why this board was chosen over others, shown with the errors; null or absent when there was no choice. */
|
|
43
|
+
boardNotice?: string | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The documents a snapshot was read from, for views that draw more than the lane lines: the sprint text, the
|
|
48
|
+
* plan texts by link as the sprint writes it, and the lanes in the order of the sprint table.
|
|
49
|
+
*/
|
|
50
|
+
export type SnapshotDocuments = {
|
|
51
|
+
sprint: string
|
|
52
|
+
plans: Record<string, string>
|
|
53
|
+
laneOrder: string[]
|
|
54
|
+
/** The board's lanes as the view draws them: the base ref's rows plus the rows only the checkout has. */
|
|
55
|
+
lanes: SprintLane[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What one read of the sprint, the plans, git and the terminal produced; keys redraw from it without
|
|
60
|
+
* reading again. `errors` are shown in the footer.
|
|
61
|
+
*/
|
|
62
|
+
export type Snapshot = {
|
|
63
|
+
title: string
|
|
64
|
+
views: LaneView[]
|
|
65
|
+
documents: SnapshotDocuments
|
|
66
|
+
/** The open phases no lane with an agent leads; empty while the view shows one lane. */
|
|
67
|
+
planned: PlannedPlan[]
|
|
68
|
+
files: string[]
|
|
69
|
+
titles: Map<string, string | null>
|
|
70
|
+
errors: string[]
|
|
71
|
+
/** The watcher's health; absent when no lane has an open pull request, so the watcher was not looked at. */
|
|
72
|
+
watch?: WatchHealth
|
|
73
|
+
/** The project's response language, for the words of the watcher's badges. */
|
|
74
|
+
language?: PlanLanguage
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type LaneSources = {
|
|
78
|
+
source: Source
|
|
79
|
+
base: BaseReader
|
|
80
|
+
sprintDirectory: string
|
|
81
|
+
agents: LaneAgent[]
|
|
82
|
+
pullRequests: PullRequestState
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type BoardLanes = { markdown: string; lanes: SprintLane[] }
|
|
86
|
+
|
|
87
|
+
const SHOWN_NAMES = 2
|
|
88
|
+
|
|
89
|
+
type LaneResult = { view: LaneView; files: string[]; texts: Record<string, string>; pane: string | null }
|
|
90
|
+
|
|
91
|
+
/** How full the agent's context is, null when the terminal gave no session or the session has no log. */
|
|
92
|
+
const contextOf = (agent: LaneAgent | undefined, source: Source): ContextReading | null => {
|
|
93
|
+
const report = contextOfAgent(agent, source.readAgentLog ?? agentLog, source.settings.context)
|
|
94
|
+
return report && { share: report.share, level: report.level }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The repository path of the plan the lane's current phase belongs to; null when every phase is done. */
|
|
98
|
+
const currentPlanPath = ({ current, steps }: LaneProgress, sprintDirectory: string): string | null => {
|
|
99
|
+
const href = current ? steps[current.stepIndex].step.planHref : null
|
|
100
|
+
return href ? resolvePlanPath(sprintDirectory, href) : null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What a lane's worktree and agent say, listed in the sprint or not: git state, pull requests, context. */
|
|
104
|
+
const runningState = async (sources: LaneSources, name: string, agent: LaneAgent | undefined) => {
|
|
105
|
+
const { source, pullRequests } = sources
|
|
106
|
+
const gitStatus = await gitStatusOf(worktreeOf(source.root, source.settings, name), baseRefOf(source.settings))
|
|
107
|
+
return {
|
|
108
|
+
gitStatus,
|
|
109
|
+
view: {
|
|
110
|
+
status: { agent: agent?.status ?? null, ...gitStatus },
|
|
111
|
+
pullRequests: gitStatus.branch ? (pullRequests.byBranch.get(gitStatus.branch) ?? []) : [],
|
|
112
|
+
context: contextOf(agent, source),
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const laneView = async (sources: LaneSources, lane: SprintLane, fallbacks: Fallbacks): Promise<LaneResult> => {
|
|
118
|
+
const { source, agents } = sources
|
|
119
|
+
const { root, settings } = source
|
|
120
|
+
const worktree = worktreeOf(root, settings, lane.name)
|
|
121
|
+
const agent = agents.find((candidate) => candidate.name === lane.name)
|
|
122
|
+
const { gitStatus, view: running } = await runningState(sources, lane.name, agent)
|
|
123
|
+
const { plans, files, texts } = await readPlans(
|
|
124
|
+
lane,
|
|
125
|
+
{ root, settings, base: sources.base, sprintDirectory: sources.sprintDirectory },
|
|
126
|
+
laneWorkOf(worktree, gitStatus),
|
|
127
|
+
fallbacks,
|
|
128
|
+
)
|
|
129
|
+
const shown = withWorkedPhases(lane, plans)
|
|
130
|
+
const progress = laneProgress(shown, plans)
|
|
131
|
+
const view: LaneView = {
|
|
132
|
+
progress,
|
|
133
|
+
...running,
|
|
134
|
+
chips: Object.fromEntries(
|
|
135
|
+
Object.entries(texts).map(([href, text]) => [href, extractLinkChips(text, settings.linkKinds)]),
|
|
136
|
+
),
|
|
137
|
+
following: currentPlanFirst(
|
|
138
|
+
followingPlans(
|
|
139
|
+
{ lane: shown, agent: agent?.status ?? null },
|
|
140
|
+
{ plans: texts, sprintDirectory: sources.sprintDirectory, language: settings.planLanguage },
|
|
141
|
+
),
|
|
142
|
+
currentPlanPath(progress, sources.sprintDirectory),
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
return { view, files, texts, pane: agent?.pane ?? null }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A running lane the sprint table does not list: its worktree's git state, pull requests and context, without plan
|
|
150
|
+
* and without phases, marked as unlisted.
|
|
151
|
+
*/
|
|
152
|
+
const unlistedView = async (sources: LaneSources, { name, agent }: UnlistedLane): Promise<LaneResult> => {
|
|
153
|
+
const { view: running } = await runningState(sources, name, agent)
|
|
154
|
+
const view: LaneView = {
|
|
155
|
+
progress: laneProgress(unlistedSprintLane(name), {}),
|
|
156
|
+
...running,
|
|
157
|
+
chips: {},
|
|
158
|
+
unlisted: true,
|
|
159
|
+
}
|
|
160
|
+
return { view, files: [], texts: {}, pane: agent.pane }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The view with the watcher's badges of its own pull requests, only when there are any, so it otherwise stays as it was. */
|
|
164
|
+
const withBadges = (view: LaneView, badges: WatchBadges): LaneView => {
|
|
165
|
+
const own = Object.fromEntries(
|
|
166
|
+
view.pullRequests.flatMap(({ number }) => {
|
|
167
|
+
const found = badges[String(number)]
|
|
168
|
+
return found ? [[String(number), found]] : []
|
|
169
|
+
}),
|
|
170
|
+
)
|
|
171
|
+
return Object.keys(own).length > 0 ? { ...view, badges: own } : view
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The terminal's agents, or none with the reason when it cannot be asked, so the screen says why lanes show "no agent". */
|
|
175
|
+
const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors: string[] } => {
|
|
176
|
+
try {
|
|
177
|
+
return { agents: terminal.listAgents(), errors: [] }
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return { agents: [], errors: [errorText(error)] }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Up to two names, then how many more: `a, b and 3 more`. */
|
|
184
|
+
const namedWithMore = (names: readonly string[]): string => {
|
|
185
|
+
const more = names.length - SHOWN_NAMES
|
|
186
|
+
return `${names.slice(0, SHOWN_NAMES).join(', ')}${more > 0 ? ` and ${more} more` : ''}`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The footer line for files read from a checkout because the base ref lacks them or does not exist. */
|
|
190
|
+
export const fallbackNotice = (fallbacks: readonly string[], baseRef: string, baseRefKnown: boolean): string | null => {
|
|
191
|
+
if (fallbacks.length === 0) return null
|
|
192
|
+
if (!baseRefKnown) return `${baseRef} not found, reading plans from the checkout`
|
|
193
|
+
return `not on ${baseRef}, read from the checkout: ${namedWithMore([...new Set(fallbacks)])}`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The board and its lanes. The text is the base ref's; rows the checkout's board file has beyond it are added
|
|
198
|
+
* (the watcher reminds the coordinator of them, the view does not mark them). A board the base ref lacks is read from the checkout alone, and a
|
|
199
|
+
* checkout with the same text adds nothing.
|
|
200
|
+
*/
|
|
201
|
+
const readBoardLanes = async (
|
|
202
|
+
source: Source,
|
|
203
|
+
base: BaseReader,
|
|
204
|
+
boardPath: string,
|
|
205
|
+
fallbacks: Fallbacks,
|
|
206
|
+
): Promise<BoardLanes> => {
|
|
207
|
+
const { sprintFile, settings } = source
|
|
208
|
+
const atBase = textAtBase(base, boardPath)
|
|
209
|
+
const markdown = atBase ?? (await readFallback(boardPath, sprintFile, fallbacks)) ?? ''
|
|
210
|
+
const checkoutText = atBase !== null && existsSync(sprintFile) ? await Bun.file(sprintFile).text() : markdown
|
|
211
|
+
return { markdown, lanes: boardRowsOf(markdown, checkoutText, settings.planLanguage).lanes }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The plans the sprint's plans tables name, read at the base ref, by link; lanes read theirs themselves. */
|
|
215
|
+
const readListedPlans = async (
|
|
216
|
+
source: Source,
|
|
217
|
+
base: BaseReader,
|
|
218
|
+
sprintDirectory: string,
|
|
219
|
+
markdown: string,
|
|
220
|
+
fallbacks: Fallbacks,
|
|
221
|
+
): Promise<Record<string, string>> => {
|
|
222
|
+
const hrefs = new Set(
|
|
223
|
+
readSprintDocument(markdown, source.settings.planLanguage).planTables.flatMap(({ rows }) =>
|
|
224
|
+
rows.flatMap((row) => (row.href ? [row.href] : [])),
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
const entries = await Promise.all(
|
|
228
|
+
[...hrefs].map(async (href) => {
|
|
229
|
+
const path = resolvePlanPath(sprintDirectory, href)
|
|
230
|
+
const text = await readAtBase(base, path, join(source.root, path), fallbacks)
|
|
231
|
+
return text === null ? [] : [[href, text] as const]
|
|
232
|
+
}),
|
|
233
|
+
)
|
|
234
|
+
return Object.fromEntries(entries.flat())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export const loadSnapshot = async (source: Source, pullRequests: PullRequestState): Promise<Snapshot> => {
|
|
238
|
+
const { root, sprintFile, options, settings, terminal } = source
|
|
239
|
+
const fallbacks: Fallbacks = []
|
|
240
|
+
const base = baseReaderOf(root, baseRefOf(settings))
|
|
241
|
+
const boardPath = boardRepoPathOf(root, settings, sprintFile)
|
|
242
|
+
const board = await readBoardLanes(source, base, boardPath, fallbacks)
|
|
243
|
+
const { markdown } = board
|
|
244
|
+
const sprintDirectory = dirname(boardPath)
|
|
245
|
+
const { agents, errors } = readAgents(terminal)
|
|
246
|
+
const isWanted = ({ name }: { name: string }) => !options.lane || name === options.lane
|
|
247
|
+
const lanes = board.lanes.filter(isWanted)
|
|
248
|
+
const unlisted = unlistedLanes(
|
|
249
|
+
agents,
|
|
250
|
+
new Set(board.lanes.map((lane) => lane.name)),
|
|
251
|
+
join(root, settings.worktreeDirectory),
|
|
252
|
+
).filter(isWanted)
|
|
253
|
+
const sources = {
|
|
254
|
+
source,
|
|
255
|
+
base,
|
|
256
|
+
sprintDirectory,
|
|
257
|
+
agents,
|
|
258
|
+
pullRequests,
|
|
259
|
+
}
|
|
260
|
+
const results = [
|
|
261
|
+
...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks)))),
|
|
262
|
+
...(await Promise.all(unlisted.map((lane) => unlistedView(sources, lane)))),
|
|
263
|
+
]
|
|
264
|
+
const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
|
|
265
|
+
const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
|
|
266
|
+
const planned = options.lane
|
|
267
|
+
? []
|
|
268
|
+
: plannedPlans({
|
|
269
|
+
plans: documentsPlans,
|
|
270
|
+
lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
|
|
271
|
+
sprintDirectory,
|
|
272
|
+
language: settings.planLanguage,
|
|
273
|
+
})
|
|
274
|
+
const reading = results.some(({ view }) => view.pullRequests.length > 0) ? readWatchReading(root, Date.now()) : null
|
|
275
|
+
const baseRef = baseRefOf(settings)
|
|
276
|
+
const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
|
|
277
|
+
const unknownLaneTitle =
|
|
278
|
+
options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
|
|
279
|
+
return {
|
|
280
|
+
title: unknownLaneTitle ?? sprintTitleOf(markdown, sprintDirectory),
|
|
281
|
+
views: sortByUrgency(results.map((result) => withBadges(result.view, reading?.badges ?? {}))),
|
|
282
|
+
documents: {
|
|
283
|
+
sprint: markdown,
|
|
284
|
+
plans: documentsPlans,
|
|
285
|
+
laneOrder: [...lanes, ...unlisted].map((lane) => lane.name),
|
|
286
|
+
lanes: board.lanes,
|
|
287
|
+
},
|
|
288
|
+
planned,
|
|
289
|
+
files: [sprintFile, ...results.flatMap((result) => result.files)],
|
|
290
|
+
titles: new Map(results.flatMap(({ view, pane }) => (pane ? [[pane, shortTitle(view.progress)]] : []))),
|
|
291
|
+
errors: [
|
|
292
|
+
...errors,
|
|
293
|
+
...pullRequests.errors,
|
|
294
|
+
...(notice ? [notice] : []),
|
|
295
|
+
...(source.boardNotice ? [source.boardNotice] : []),
|
|
296
|
+
],
|
|
297
|
+
watch: reading?.health,
|
|
298
|
+
language: settings.responseLanguage,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { openInBrowser } from '../terminal/open'
|
|
6
|
+
import { errorText } from './error-text'
|
|
7
|
+
import { baseRefInfo, remoteBlobBase } from './git'
|
|
8
|
+
import { baseRefOf, boardRepoPathOf } from './settings'
|
|
9
|
+
import type { Snapshot, Source } from './snapshot'
|
|
10
|
+
import { renderPlanBody, renderSprintHtml } from './sprint-html'
|
|
11
|
+
import { buildSprintPage, type SprintPage, sprintPagePath } from './sprint-page'
|
|
12
|
+
import { boardTexts } from './texts'
|
|
13
|
+
|
|
14
|
+
const DIRECTORY_MODE = 0o700
|
|
15
|
+
const FILE_MODE = 0o600
|
|
16
|
+
|
|
17
|
+
/** What writing and opening the page needs from outside; the defaults are the real ones. */
|
|
18
|
+
export type ExportEnvironment = {
|
|
19
|
+
now: () => Date
|
|
20
|
+
temporaryDirectory: string
|
|
21
|
+
open: (target: string) => string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const systemEnvironment = (): ExportEnvironment => ({
|
|
25
|
+
now: () => new Date(),
|
|
26
|
+
temporaryDirectory: tmpdir(),
|
|
27
|
+
open: (target) => openInBrowser(target),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/** A page on disk: where it is, and the section id of each plan it carries by the plan's repository path. */
|
|
31
|
+
export type WrittenPage = { path: string; anchors: ReadonlyMap<string, string> }
|
|
32
|
+
|
|
33
|
+
const pageOf = ({ root, sprintFile, settings }: Source, snapshot: Snapshot, environment: ExportEnvironment) =>
|
|
34
|
+
buildSprintPage(snapshot, {
|
|
35
|
+
root,
|
|
36
|
+
generatedAt: environment.now(),
|
|
37
|
+
baseRef: baseRefInfo(root, baseRefOf(settings)),
|
|
38
|
+
repoBlobBase: remoteBlobBase(root, settings.baseBranch),
|
|
39
|
+
sprintDirectory: dirname(boardRepoPathOf(root, settings, sprintFile)),
|
|
40
|
+
language: settings.planLanguage,
|
|
41
|
+
linkKinds: settings.linkKinds,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const writePage = (
|
|
45
|
+
page: SprintPage,
|
|
46
|
+
{ sprintFile, root, settings }: Source,
|
|
47
|
+
environment: ExportEnvironment,
|
|
48
|
+
): WrittenPage | { error: string } => {
|
|
49
|
+
const path = sprintPagePath(environment.temporaryDirectory, dirname(boardRepoPathOf(root, settings, sprintFile)))
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(dirname(path), { recursive: true, mode: DIRECTORY_MODE })
|
|
52
|
+
writeFileSync(path, renderSprintHtml(page), { mode: FILE_MODE })
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return { error: errorText(error) }
|
|
55
|
+
}
|
|
56
|
+
return { path, anchors: page.links.anchors }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The page as a file, without opening it; the reason when it could not be written. */
|
|
60
|
+
export const writeSprintPageFile = (
|
|
61
|
+
source: Source,
|
|
62
|
+
snapshot: Snapshot,
|
|
63
|
+
environment: ExportEnvironment = systemEnvironment(),
|
|
64
|
+
): WrittenPage | { error: string } => writePage(pageOf(source, snapshot, environment), source, environment)
|
|
65
|
+
|
|
66
|
+
const yieldToEventLoop = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The same as `writeSprintPageFile`, but the plans are rendered one at a time with the event loop given back in
|
|
70
|
+
* between, so a key pressed while the page is built waits for one plan (a few hundred milliseconds at most), not
|
|
71
|
+
* for all of them (seconds). The final write then finds every plan already rendered.
|
|
72
|
+
*/
|
|
73
|
+
export const writeSprintPageInSteps = async (
|
|
74
|
+
source: Source,
|
|
75
|
+
snapshot: Snapshot,
|
|
76
|
+
environment: ExportEnvironment = systemEnvironment(),
|
|
77
|
+
): Promise<WrittenPage | { error: string }> => {
|
|
78
|
+
const page = pageOf(source, snapshot, environment)
|
|
79
|
+
for (const document of page.planDocuments) {
|
|
80
|
+
renderPlanBody(document, page)
|
|
81
|
+
await yieldToEventLoop()
|
|
82
|
+
}
|
|
83
|
+
return writePage(page, source, environment)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Writes the whole board as one HTML file and opens it in the default browser, at `anchor` when given. Returns
|
|
88
|
+
* the footer line: the path when it was opened, the path and the reason when it could not be (no display is no
|
|
89
|
+
* error), the reason when nothing could be written.
|
|
90
|
+
*/
|
|
91
|
+
export const exportSprintPage = (
|
|
92
|
+
source: Source,
|
|
93
|
+
snapshot: Snapshot,
|
|
94
|
+
environment: ExportEnvironment = systemEnvironment(),
|
|
95
|
+
anchor: string | null = null,
|
|
96
|
+
): string => {
|
|
97
|
+
const texts = boardTexts(source.settings.board.term)
|
|
98
|
+
const written = writeSprintPageFile(source, snapshot, environment)
|
|
99
|
+
if ('error' in written) return texts.pageNotWritten(written.error)
|
|
100
|
+
const { path } = written
|
|
101
|
+
const reason = environment.open(anchor ? `${pathToFileURL(path).href}#${anchor}` : path)
|
|
102
|
+
return reason === null ? texts.pageOpened(path) : texts.pageNotOpened(path, reason)
|
|
103
|
+
}
|