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,352 @@
|
|
|
1
|
+
import { currentPhase, type LaneProgress } from '../plan-format'
|
|
2
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
3
|
+
import { chipUrlOf, isChipKey, planUrlOf } from './link-chips'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether a lane is drawn open, and the agent status that was true when that was decided. The fold
|
|
7
|
+
* counts only while the lane still has that status: a change of status hands the decision back to
|
|
8
|
+
* the automatic rule (`autoOpen`), so a lane that starts working opens by itself even if a person closed it.
|
|
9
|
+
*/
|
|
10
|
+
export type LaneFold = { status: string | null; open: boolean }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The row under the cursor and the expanded phases, both as keys, whether ticked phases and items are
|
|
14
|
+
* listed one by one or summed up in one line, whether the footer shows the symbol legend, and the
|
|
15
|
+
* folds of the lanes by lane name, and the lanes whose notes (sprint rows without a phase) are listed.
|
|
16
|
+
*/
|
|
17
|
+
export type Selection = {
|
|
18
|
+
selected: string | null
|
|
19
|
+
expanded: ReadonlySet<string>
|
|
20
|
+
showDone: boolean
|
|
21
|
+
showLegend: boolean
|
|
22
|
+
laneFolds: ReadonlyMap<string, LaneFold>
|
|
23
|
+
notesShown: ReadonlySet<string>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A lane as the folds need it: its name and the status its agent reports, null without an agent. */
|
|
27
|
+
export type LaneStatusEntry = { lane: string; status: string | null }
|
|
28
|
+
|
|
29
|
+
/** What the screen shows: the selection, the first visible body line, and whether the window follows the cursor. */
|
|
30
|
+
export type View = { selection: Selection; top: number; follow: boolean }
|
|
31
|
+
|
|
32
|
+
/** What an input needs to know about the frame on screen. */
|
|
33
|
+
export type InputContext = {
|
|
34
|
+
keys: readonly string[]
|
|
35
|
+
targets: readonly ClickTarget[]
|
|
36
|
+
headerRows: number
|
|
37
|
+
bodyRows: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A clickable span of a frame line, columns 0-based and `to` exclusive. */
|
|
41
|
+
export type ClickTarget = { line: number; from: number; to: number; key: string }
|
|
42
|
+
|
|
43
|
+
/** An SGR mouse report; column and row are 1-based as the terminal sends them. */
|
|
44
|
+
export type MouseEvent = { button: number; column: number; row: number; pressed: boolean }
|
|
45
|
+
|
|
46
|
+
export const INPUT = {
|
|
47
|
+
up: ['\x1b[A', '\x1bOA', 'k'],
|
|
48
|
+
down: ['\x1b[B', '\x1bOB', 'j'],
|
|
49
|
+
toggle: ['\r', ' '],
|
|
50
|
+
expand: ['\x1b[C', '\x1bOC', 'l'],
|
|
51
|
+
collapse: ['\x1b[D', '\x1bOD', 'h'],
|
|
52
|
+
showDone: ['a'],
|
|
53
|
+
legend: ['?'],
|
|
54
|
+
notes: ['n'],
|
|
55
|
+
sprintPage: ['s'],
|
|
56
|
+
pageUp: ['\x1b[5~'],
|
|
57
|
+
pageDown: ['\x1b[6~'],
|
|
58
|
+
} as const
|
|
59
|
+
|
|
60
|
+
const WHEEL_LINES = 3
|
|
61
|
+
|
|
62
|
+
const ESC = '\x1b'
|
|
63
|
+
const ESCAPE_SEQUENCE = new RegExp(`${ESC}(?:\\[[<0-9;]*[A-Za-z~]|O[A-Za-z])|[\\s\\S]`, 'gu')
|
|
64
|
+
const MOUSE_REPORT = new RegExp(`^${ESC}\\[<(\\d+);(\\d+);(\\d+)([Mm])$`)
|
|
65
|
+
|
|
66
|
+
const MOUSE_BUTTON = { left: 0, wheelUp: 64, wheelDown: 65 } as const
|
|
67
|
+
|
|
68
|
+
/** One terminal read can carry several key presses; this splits it into keys, escape sequences kept whole. */
|
|
69
|
+
export const splitKeys = (chunk: string): string[] => chunk.match(ESCAPE_SEQUENCE) ?? []
|
|
70
|
+
|
|
71
|
+
/** The key of the closing row that lists the planned phases; no lane key starts without `@`, no phase key lacks a colon. */
|
|
72
|
+
export const PLANNED_KEY = 'planned'
|
|
73
|
+
|
|
74
|
+
export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
|
|
75
|
+
|
|
76
|
+
const NOTES_KEY_PREFIX = 'notes>'
|
|
77
|
+
const FOLLOWING_KEY_PREFIX = 'following>'
|
|
78
|
+
const TICKED_KEY_PREFIX = 'ticked>'
|
|
79
|
+
|
|
80
|
+
/** The key of the row that shows or hides the notes of a lane; selectable, since a key reaches it too. */
|
|
81
|
+
export const notesKey = (lane: string): string => `${NOTES_KEY_PREFIX}${lane}`
|
|
82
|
+
|
|
83
|
+
/** The key of the folded row under a lane that lists the open phases after its own. */
|
|
84
|
+
export const followingKey = (lane: string): string => `${FOLLOWING_KEY_PREFIX}${lane}`
|
|
85
|
+
|
|
86
|
+
/** The key of the "N ticked" line of an open phase; it is clicked only, and a click lands on the phase. */
|
|
87
|
+
export const tickedKey = (phase: string): string => `${TICKED_KEY_PREFIX}${phase}`
|
|
88
|
+
|
|
89
|
+
const ITEM_KEY = /^(.+:\d+:[^:#]+)#(\d+)$/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The key of a task row: its phase's key and the task's index in `phase.items`; a click or Enter opens its text.
|
|
93
|
+
* The index counts the checkboxes of the phase, so a checkbox inserted above a task shifts which task an open
|
|
94
|
+
* key names after the plan is reloaded; a task stays open by position, not by text.
|
|
95
|
+
*/
|
|
96
|
+
export const itemKey = (phase: string, index: number): string => `${phase}#${index}`
|
|
97
|
+
|
|
98
|
+
/** The phase a task key belongs to, null for any other key. */
|
|
99
|
+
export const itemPhaseOfKey = (key: string): string | null => key.match(ITEM_KEY)?.[1] ?? null
|
|
100
|
+
|
|
101
|
+
const notesLaneOfKey = (key: string): string | null =>
|
|
102
|
+
key.startsWith(NOTES_KEY_PREFIX) ? key.slice(NOTES_KEY_PREFIX.length) : null
|
|
103
|
+
|
|
104
|
+
const followingLaneOfKey = (key: string): string | null =>
|
|
105
|
+
key.startsWith(FOLLOWING_KEY_PREFIX) ? key.slice(FOLLOWING_KEY_PREFIX.length) : null
|
|
106
|
+
|
|
107
|
+
const tickedPhaseOfKey = (key: string): string | null =>
|
|
108
|
+
key.startsWith(TICKED_KEY_PREFIX) ? key.slice(TICKED_KEY_PREFIX.length) : null
|
|
109
|
+
|
|
110
|
+
const LANE_KEY_PREFIX = '@'
|
|
111
|
+
|
|
112
|
+
/** The key of a lane's own row; it cannot collide with a phase key, which starts with a lane name and a colon. */
|
|
113
|
+
export const laneKey = (lane: string): string => `${LANE_KEY_PREFIX}${lane}`
|
|
114
|
+
|
|
115
|
+
const laneOfKey = (key: string): string | null => (key.startsWith(LANE_KEY_PREFIX) ? key.slice(1) : null)
|
|
116
|
+
|
|
117
|
+
const PHASE_KEY = /^(.+):\d+:[^:]+$/
|
|
118
|
+
|
|
119
|
+
/** The lane a row belongs to: its own row or one of its phases; null for a pull request row. */
|
|
120
|
+
const laneNameOfKey = (key: string): string | null =>
|
|
121
|
+
laneOfKey(key) ?? notesLaneOfKey(key) ?? followingLaneOfKey(key) ?? key.match(PHASE_KEY)?.[1] ?? null
|
|
122
|
+
|
|
123
|
+
/** Working and blocked lanes show their phases; idle and done ones, and lanes without an agent, fold into one line. */
|
|
124
|
+
export const autoOpen = (status: string | null): boolean =>
|
|
125
|
+
status !== null && status !== AGENT_STATUS.idle && status !== AGENT_STATUS.done
|
|
126
|
+
|
|
127
|
+
/** Whether a lane is drawn open: the person's choice while its status is unchanged, else the automatic rule. */
|
|
128
|
+
export const isLaneOpen = ({ laneFolds }: Selection, lane: string, status: string | null): boolean => {
|
|
129
|
+
const fold = laneFolds.get(lane)
|
|
130
|
+
return fold && fold.status === status ? fold.open : autoOpen(status)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Lets the folds follow the lanes: a lane keeps its fold while its status is the same, gets the
|
|
135
|
+
* automatic one when its status changed or it is new, and loses it when it left the sprint. Returns
|
|
136
|
+
* the same object when nothing changed.
|
|
137
|
+
*/
|
|
138
|
+
export const syncLaneFolds = (selection: Selection, lanes: readonly LaneStatusEntry[]): Selection => {
|
|
139
|
+
const folds = new Map<string, LaneFold>()
|
|
140
|
+
for (const { lane, status } of lanes) {
|
|
141
|
+
const fold = selection.laneFolds.get(lane)
|
|
142
|
+
folds.set(lane, fold && fold.status === status ? fold : { status, open: autoOpen(status) })
|
|
143
|
+
}
|
|
144
|
+
const unchanged =
|
|
145
|
+
folds.size === selection.laneFolds.size &&
|
|
146
|
+
[...folds].every(([lane, fold]) => selection.laneFolds.get(lane) === fold)
|
|
147
|
+
return unchanged ? selection : { ...selection, laneFolds: folds }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const overviewSelection = (): Selection => ({
|
|
151
|
+
selected: null,
|
|
152
|
+
expanded: new Set(),
|
|
153
|
+
showDone: false,
|
|
154
|
+
showLegend: false,
|
|
155
|
+
laneFolds: new Map(),
|
|
156
|
+
notesShown: new Set(),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
/** The lane view opens its lane and, when it has a current phase, that phase, expanded. */
|
|
160
|
+
export const laneSelection = (progress: LaneProgress | undefined, status: string | null): Selection => {
|
|
161
|
+
const base = overviewSelection()
|
|
162
|
+
if (!progress) return base
|
|
163
|
+
const opened = { ...base, laneFolds: new Map([[progress.lane.name, { status, open: true }]]) }
|
|
164
|
+
const phase = currentPhase(progress)
|
|
165
|
+
if (!progress.current || !phase) return opened
|
|
166
|
+
const key = phaseKey(progress.lane.name, progress.current.stepIndex, phase.id)
|
|
167
|
+
return { ...opened, selected: key, expanded: new Set([key]) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The selectable rows in screen order, once each: only what is drawn can be selected; a chip or a "N ticked" line is clicked, not selected. */
|
|
171
|
+
export const targetKeys = (targets: readonly ClickTarget[]): string[] => [
|
|
172
|
+
...new Set(targets.map((target) => target.key).filter((key) => !isChipKey(key) && tickedPhaseOfKey(key) === null)),
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
/** The selected key, or the first one while nothing is selected or the selected phase left the sprint. */
|
|
176
|
+
export const selectedKey = ({ selected }: Selection, keys: readonly string[]): string | null =>
|
|
177
|
+
selected && keys.includes(selected) ? selected : (keys[0] ?? null)
|
|
178
|
+
|
|
179
|
+
const isInput = (group: readonly string[], input: string): boolean => group.includes(input)
|
|
180
|
+
|
|
181
|
+
const move = (selection: Selection, keys: readonly string[], step: number): Selection => {
|
|
182
|
+
const current = selectedKey(selection, keys)
|
|
183
|
+
const index = current ? keys.indexOf(current) : 0
|
|
184
|
+
const next = keys[Math.min(Math.max(index + step, 0), keys.length - 1)] ?? null
|
|
185
|
+
return { ...selection, selected: next }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const toggleIn = (keys: Set<string>, key: string): void => {
|
|
189
|
+
if (!keys.delete(key)) keys.add(key)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const opened = () => true
|
|
193
|
+
const closed = () => false
|
|
194
|
+
const flipped = (open: boolean) => !open
|
|
195
|
+
|
|
196
|
+
/** Changes the fold of a lane; a lane without a fold is left alone, which `syncLaneFolds` rules out once a snapshot is loaded. */
|
|
197
|
+
const foldLane = (selection: Selection, lane: string, next: (open: boolean) => boolean): Selection => {
|
|
198
|
+
const fold = selection.laneFolds.get(lane)
|
|
199
|
+
if (!fold) return selection
|
|
200
|
+
return { ...selection, laneFolds: new Map(selection.laneFolds).set(lane, { ...fold, open: next(fold.open) }) }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const withNotes = (selection: Selection, lane: string, next: (shown: boolean) => boolean): Selection => {
|
|
204
|
+
const notesShown = new Set(selection.notesShown)
|
|
205
|
+
if (next(notesShown.has(lane))) notesShown.add(lane)
|
|
206
|
+
else notesShown.delete(lane)
|
|
207
|
+
return { ...selection, notesShown }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Opens a task and the phase it belongs to. */
|
|
211
|
+
const openTaskWithPhase = (expanded: Set<string>, phase: string, task: string): void => {
|
|
212
|
+
expanded.add(phase)
|
|
213
|
+
expanded.add(task)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Selects a row and opens it, or closes it when it is already open: a phase shows its items, a lane its phases. */
|
|
217
|
+
export const applyClick = (selection: Selection, key: string): Selection => {
|
|
218
|
+
const notesLane = notesLaneOfKey(key)
|
|
219
|
+
if (notesLane !== null) return { ...withNotes(selection, notesLane, flipped), selected: key }
|
|
220
|
+
const phase = tickedPhaseOfKey(key)
|
|
221
|
+
if (phase !== null) return { ...selection, showDone: !selection.showDone, selected: phase }
|
|
222
|
+
const lane = laneOfKey(key)
|
|
223
|
+
if (lane !== null) return { ...foldLane(selection, lane, flipped), selected: key }
|
|
224
|
+
const expanded = new Set(selection.expanded)
|
|
225
|
+
const itemPhase = itemPhaseOfKey(key)
|
|
226
|
+
if (itemPhase !== null && !expanded.has(itemPhase)) openTaskWithPhase(expanded, itemPhase, key)
|
|
227
|
+
else toggleIn(expanded, key)
|
|
228
|
+
return { ...selection, selected: key, expanded }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const toggle = (selection: Selection, keys: readonly string[]): Selection => {
|
|
232
|
+
const current = selectedKey(selection, keys)
|
|
233
|
+
return current ? applyClick(selection, current) : selection
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const changeSelected = (selection: Selection, keys: readonly string[], next: (open: boolean) => boolean): Selection => {
|
|
237
|
+
const current = selectedKey(selection, keys)
|
|
238
|
+
if (!current) return selection
|
|
239
|
+
const notesLane = notesLaneOfKey(current)
|
|
240
|
+
if (notesLane !== null) return { ...withNotes(selection, notesLane, next), selected: current }
|
|
241
|
+
const lane = laneOfKey(current)
|
|
242
|
+
if (lane !== null) return { ...foldLane(selection, lane, next), selected: current }
|
|
243
|
+
const expanded = new Set(selection.expanded)
|
|
244
|
+
if (next(expanded.has(current))) {
|
|
245
|
+
expanded.add(current)
|
|
246
|
+
const itemPhase = itemPhaseOfKey(current)
|
|
247
|
+
if (itemPhase !== null) expanded.add(itemPhase)
|
|
248
|
+
} else expanded.delete(current)
|
|
249
|
+
return { ...selection, selected: current, expanded }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const expand = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, opened)
|
|
253
|
+
|
|
254
|
+
const collapse = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, closed)
|
|
255
|
+
|
|
256
|
+
/** Lists or hides the notes of the lane under the cursor. */
|
|
257
|
+
const toggleNotes = (selection: Selection, keys: readonly string[]): Selection => {
|
|
258
|
+
const current = selectedKey(selection, keys)
|
|
259
|
+
const lane = current ? laneNameOfKey(current) : null
|
|
260
|
+
if (!lane) return selection
|
|
261
|
+
return withNotes(selection, lane, flipped)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export const applyKey = (selection: Selection, input: string, keys: readonly string[]): Selection => {
|
|
265
|
+
if (isInput(INPUT.up, input)) return move(selection, keys, -1)
|
|
266
|
+
if (isInput(INPUT.down, input)) return move(selection, keys, 1)
|
|
267
|
+
if (isInput(INPUT.toggle, input)) return toggle(selection, keys)
|
|
268
|
+
if (isInput(INPUT.expand, input)) return expand(selection, keys)
|
|
269
|
+
if (isInput(INPUT.collapse, input)) return collapse(selection, keys)
|
|
270
|
+
if (isInput(INPUT.showDone, input)) return { ...selection, showDone: !selection.showDone }
|
|
271
|
+
if (isInput(INPUT.notes, input)) return toggleNotes(selection, keys)
|
|
272
|
+
if (isInput(INPUT.legend, input)) return { ...selection, showLegend: !selection.showLegend }
|
|
273
|
+
return selection
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The key that writes the whole sprint as an HTML page and opens it. */
|
|
277
|
+
export const isSprintPageKey = (input: string): boolean => isInput(INPUT.sprintPage, input)
|
|
278
|
+
|
|
279
|
+
export const parseMouse = (key: string): MouseEvent | null => {
|
|
280
|
+
const match = key.match(MOUSE_REPORT)
|
|
281
|
+
if (!match) return null
|
|
282
|
+
return { button: Number(match[1]), column: Number(match[2]), row: Number(match[3]), pressed: match[4] === 'M' }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const targetAt = (targets: readonly ClickTarget[], line: number, column: number): string | null =>
|
|
286
|
+
targets.find((target) => target.line === line && column >= target.from && column < target.to)?.key ?? null
|
|
287
|
+
|
|
288
|
+
const scrollDelta = (input: string, mouse: MouseEvent | null, bodyRows: number): number => {
|
|
289
|
+
if (mouse?.button === MOUSE_BUTTON.wheelUp) return -WHEEL_LINES
|
|
290
|
+
if (mouse?.button === MOUSE_BUTTON.wheelDown) return WHEEL_LINES
|
|
291
|
+
if (isInput(INPUT.pageUp, input)) return -Math.max(bodyRows - 1, 1)
|
|
292
|
+
if (isInput(INPUT.pageDown, input)) return Math.max(bodyRows - 1, 1)
|
|
293
|
+
return 0
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const clicked = (mouse: MouseEvent, view: View, { targets, headerRows }: InputContext): string | null => {
|
|
297
|
+
if (!mouse.pressed || mouse.button !== MOUSE_BUTTON.left) return null
|
|
298
|
+
return targetAt(targets, view.top + mouse.row - 1 - headerRows, mouse.column - 1)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Applies one key or mouse report. Wheel and page keys move the window and stop it following the
|
|
303
|
+
* cursor; everything that changes the selection makes it follow again.
|
|
304
|
+
*/
|
|
305
|
+
export const applyInput = (view: View, input: string, context: InputContext): View => {
|
|
306
|
+
const mouse = parseMouse(input)
|
|
307
|
+
const delta = scrollDelta(input, mouse, context.bodyRows)
|
|
308
|
+
if (delta !== 0) return { ...view, top: Math.max(view.top + delta, 0), follow: false }
|
|
309
|
+
const key = mouse ? clicked(mouse, view, context) : null
|
|
310
|
+
if (mouse)
|
|
311
|
+
return key && !isChipKey(key) ? { ...view, selection: applyClick(view.selection, key), follow: true } : view
|
|
312
|
+
const selection = applyKey(view.selection, input, context.keys)
|
|
313
|
+
return selection === view.selection ? view : { ...view, selection, follow: true }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The row an input acts on: enter, space or right on the selected row, or a left click; null for anything else. */
|
|
317
|
+
export const activatedKey = (view: View, input: string, context: InputContext): string | null => {
|
|
318
|
+
const mouse = parseMouse(input)
|
|
319
|
+
if (mouse) return clicked(mouse, view, context)
|
|
320
|
+
const activates = isInput(INPUT.toggle, input) || isInput(INPUT.expand, input)
|
|
321
|
+
return activates ? selectedKey(view.selection, context.keys) : null
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* What one input does: a click on a chip asks to open its link and leaves the selection alone; activating a row
|
|
326
|
+
* that has a link (a pull request) selects it and asks to open the link; every other input goes through `applyInput`. `linkOf` answers the link behind a row key.
|
|
327
|
+
*/
|
|
328
|
+
export const inputOutcome = (
|
|
329
|
+
view: View,
|
|
330
|
+
input: string,
|
|
331
|
+
context: InputContext,
|
|
332
|
+
linkOf: (key: string) => string | null,
|
|
333
|
+
): { view: View; open: string | null } => {
|
|
334
|
+
const activated = activatedKey(view, input, context)
|
|
335
|
+
const chipUrl = activated ? (chipUrlOf(activated) ?? planUrlOf(activated)) : null
|
|
336
|
+
if (chipUrl) return { view, open: chipUrl }
|
|
337
|
+
const link = activated ? linkOf(activated) : null
|
|
338
|
+
if (!link) return { view: applyInput(view, input, context), open: null }
|
|
339
|
+
return { view: { ...view, selection: { ...view.selection, selected: activated }, follow: true }, open: link }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The first visible body line: following the cursor, or where the user scrolled, never past the last line. */
|
|
343
|
+
export const windowTop = (view: View, cursorLine: number, lineCount: number, rows: number): number =>
|
|
344
|
+
view.follow && cursorLine >= 0
|
|
345
|
+
? scrollTop(view.top, cursorLine, lineCount, rows)
|
|
346
|
+
: Math.min(view.top, Math.max(lineCount - rows, 0))
|
|
347
|
+
|
|
348
|
+
/** The first visible line of a window of `rows` lines that keeps `cursorLine` in view, moving as little as it can. */
|
|
349
|
+
export const scrollTop = (top: number, cursorLine: number, lineCount: number, rows: number): number => {
|
|
350
|
+
const followed = cursorLine < top ? cursorLine : cursorLine >= top + rows ? cursorLine - rows + 1 : top
|
|
351
|
+
return Math.min(followed, Math.max(lineCount - rows, 0))
|
|
352
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
import { type JackherdConfig, parseJackherdConfig } from '../config'
|
|
4
|
+
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
5
|
+
import { readConfig } from '../config/read-config'
|
|
6
|
+
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
7
|
+
|
|
8
|
+
export const CONFIG_PATH = PROJECT_CONFIG_PATH
|
|
9
|
+
|
|
10
|
+
export type PlansSettings = Pick<
|
|
11
|
+
JackherdConfig,
|
|
12
|
+
'planDirectory' | 'board' | 'worktreeDirectory' | 'baseBranch' | 'planLanguage' | 'responseLanguage'
|
|
13
|
+
> & {
|
|
14
|
+
adapter: JackherdConfig['terminal']['adapter']
|
|
15
|
+
fetchIntervalMinutes: number
|
|
16
|
+
linkKinds: JackherdConfig['plans']['linkKinds']
|
|
17
|
+
context: JackherdConfig['plans']['context']
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CLOCK_LOCALE: Record<PlanLanguage, string> = {
|
|
21
|
+
[PLAN_LANGUAGE.de]: 'de-DE',
|
|
22
|
+
[PLAN_LANGUAGE.en]: 'en-GB',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The plans view's slice of a validated config. */
|
|
26
|
+
export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
27
|
+
planDirectory: config.planDirectory,
|
|
28
|
+
board: config.board,
|
|
29
|
+
worktreeDirectory: config.worktreeDirectory,
|
|
30
|
+
baseBranch: config.baseBranch,
|
|
31
|
+
planLanguage: config.planLanguage,
|
|
32
|
+
responseLanguage: config.responseLanguage,
|
|
33
|
+
adapter: config.terminal.adapter,
|
|
34
|
+
fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
|
|
35
|
+
linkKinds: config.plans.linkKinds,
|
|
36
|
+
context: config.plans.context,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/** The plans view's slice of `.jackherd/config.json`, validated by the shared schema. */
|
|
40
|
+
export const parsePlansSettings = (raw: unknown): PlansSettings => plansSettingsOf(parseJackherdConfig(raw ?? {}))
|
|
41
|
+
|
|
42
|
+
/** Board documents, relative to the main checkout. */
|
|
43
|
+
export const boardGlobOf = (settings: PlansSettings): string =>
|
|
44
|
+
`${settings.planDirectory}/${settings.board.pattern}/${settings.board.file}`
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A board file's path in the repository: relative to the main checkout `root`, or, for a board inside a lane's
|
|
48
|
+
* worktree under `worktreeDirectory`, relative to that worktree. Both name the same file on the base branch.
|
|
49
|
+
*/
|
|
50
|
+
export const boardRepoPathOf = (root: string, settings: PlansSettings, file: string): string => {
|
|
51
|
+
const path = relative(root, file)
|
|
52
|
+
const prefix = `${settings.worktreeDirectory}/`
|
|
53
|
+
return path.startsWith(prefix) ? path.slice(prefix.length).split('/').slice(1).join('/') : path
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const worktreeOf = (root: string, settings: PlansSettings, laneName: string): string =>
|
|
57
|
+
join(root, settings.worktreeDirectory, laneName)
|
|
58
|
+
|
|
59
|
+
/** The remote whose base branch the view reads plans and the board from. */
|
|
60
|
+
export const BASE_REMOTE = 'origin'
|
|
61
|
+
|
|
62
|
+
export const baseRefOf = ({ baseBranch }: Pick<PlansSettings, 'baseBranch'>): string => `${BASE_REMOTE}/${baseBranch}`
|
|
63
|
+
|
|
64
|
+
export const clockLocaleOf = (settings: PlansSettings): string => CLOCK_LOCALE[settings.responseLanguage]
|
|
65
|
+
|
|
66
|
+
/** The plans view's settings from the config `readConfig` finds. */
|
|
67
|
+
export const readSettings = (roots: string[], home: string = homedir()): PlansSettings =>
|
|
68
|
+
plansSettingsOf(readConfig(roots, home))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `task` one at a time. A call made while it runs does not start a second one alongside: it is remembered,
|
|
3
|
+
* and the task runs once more when the first has finished, however many calls came meanwhile. Every caller's
|
|
4
|
+
* promise settles when the run it asked for is done.
|
|
5
|
+
*/
|
|
6
|
+
export const singleFlight = (task: () => Promise<void>): (() => Promise<void>) => {
|
|
7
|
+
let running: Promise<void> | null = null
|
|
8
|
+
let again = false
|
|
9
|
+
const run = async (): Promise<void> => {
|
|
10
|
+
do {
|
|
11
|
+
again = false
|
|
12
|
+
await task()
|
|
13
|
+
} while (again)
|
|
14
|
+
}
|
|
15
|
+
return () => {
|
|
16
|
+
if (running !== null) {
|
|
17
|
+
again = true
|
|
18
|
+
return running
|
|
19
|
+
}
|
|
20
|
+
running = run().finally(() => {
|
|
21
|
+
running = null
|
|
22
|
+
})
|
|
23
|
+
return running
|
|
24
|
+
}
|
|
25
|
+
}
|