jackherd 0.0.0-stage → 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.
Files changed (250) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1123 -2
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +65 -4
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,124 @@
1
+ import { type ChildProcess, spawn } from 'node:child_process'
2
+ import { watch } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { pendingKitUpdate, VERSION_POLL_MS } from '../version/installed-version'
6
+ import type { StartedKit } from '../version/started-kit'
7
+ import { CHILD_MARKER, exitCodeOf, isRestartTrigger, nextStep, RESTART_DEBOUNCE_MS } from './restart-decision'
8
+ import { TERMINAL_OFF } from './view'
9
+
10
+ const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
11
+
12
+ const packageSourceDirectory = (): string => dirname(dirname(fileURLToPath(import.meta.url)))
13
+
14
+ const entryScript = (): string => {
15
+ const script = process.argv[1]
16
+ if (!script) throw new Error('Cannot supervise the plans view: process.argv[1] is missing')
17
+ return script
18
+ }
19
+
20
+ const describeEnd = (code: number | null, signal: NodeJS.Signals | null): string =>
21
+ signal ? `signal ${signal}` : `exit ${code}`
22
+
23
+ const spawnView = (scriptPath: string, args: string[]): ChildProcess =>
24
+ spawn(process.execPath, [scriptPath, 'plans', ...args], {
25
+ stdio: 'inherit',
26
+ env: { ...process.env, [CHILD_MARKER]: '1' },
27
+ })
28
+
29
+ /**
30
+ * Runs the live view as a child process and restarts it when the package's own source changes,
31
+ * so a long-lived `plans` never shows the behaviour of code that has since been edited. The child
32
+ * stops on SIGTERM through the handler that restores the terminal. Resolves with the exit code
33
+ * of a wanted end only: the child's clean exit (`q`) or a signal forwarded to it. A child that
34
+ * dies otherwise leaves the supervisor waiting, with the terminal restored, for the next change
35
+ * to `src/`, which spawns a fresh child. `kit` is the package this process loaded (null when it cannot be
36
+ * located, which turns the version check off). A failure to spawn the very first child resolves with 1.
37
+ *
38
+ * Meant to run once per process: the signal listeners it registers stay registered.
39
+ */
40
+ export const superviseView = (
41
+ args: string[],
42
+ kit: StartedKit | null,
43
+ scriptPath: string = entryScript(),
44
+ ): Promise<number> =>
45
+ new Promise((resolveExit) => {
46
+ let child: ChildProcess | undefined
47
+ let restarting = false
48
+ let stopping = false
49
+ let timer: ReturnType<typeof setTimeout> | undefined
50
+ let runningKit = kit
51
+
52
+ const finish = (code: number): void => {
53
+ clearTimeout(timer)
54
+ clearInterval(versionTimer)
55
+ watcher.close()
56
+ resolveExit(code)
57
+ }
58
+
59
+ const waitForChange = (reason: string): void => {
60
+ child = undefined
61
+ process.stdout.write(TERMINAL_OFF)
62
+ console.log(`plans view stopped (${reason}); waiting for the next change to src/`)
63
+ }
64
+
65
+ const start = (isFirst: boolean): void => {
66
+ const current = spawnView(scriptPath, args)
67
+ child = current
68
+
69
+ current.on('error', (error) => {
70
+ if (child !== current) return
71
+ if (isFirst) {
72
+ console.error(`plans view could not start: ${error.message}`)
73
+ finish(1)
74
+ return
75
+ }
76
+ console.error(error.message)
77
+ waitForChange('error')
78
+ })
79
+
80
+ current.on('exit', (code, signal) => {
81
+ if (child !== current) return
82
+ const context = { restarting, stopping, code, signal }
83
+ restarting = false
84
+ const actions = {
85
+ respawn: () => start(false),
86
+ exit: () => finish(exitCodeOf(code, signal)),
87
+ wait: () => waitForChange(describeEnd(code, signal)),
88
+ }
89
+ actions[nextStep(context)]()
90
+ })
91
+ }
92
+
93
+ const restartView = (): void => {
94
+ if (child) {
95
+ restarting = true
96
+ child.kill('SIGTERM')
97
+ } else start(false)
98
+ }
99
+
100
+ const watcher = watch(packageSourceDirectory(), { recursive: true }, (_event, name) => {
101
+ if (stopping || !name || !isRestartTrigger(name.toString())) return
102
+ clearTimeout(timer)
103
+ timer = setTimeout(restartView, RESTART_DEBOUNCE_MS)
104
+ })
105
+
106
+ const versionTimer = setInterval(() => {
107
+ const update = pendingKitUpdate(runningKit)
108
+ if (stopping || !update || !runningKit) return
109
+ runningKit = { ...runningKit, version: update.to }
110
+ restartView()
111
+ }, VERSION_POLL_MS)
112
+
113
+ start(true)
114
+
115
+ for (const signal of FORWARDED_SIGNALS) {
116
+ process.on(signal, () => {
117
+ stopping = true
118
+ clearTimeout(timer)
119
+ clearInterval(versionTimer)
120
+ if (child) child.kill(signal)
121
+ else finish(exitCodeOf(null, signal))
122
+ })
123
+ }
124
+ })
@@ -0,0 +1,54 @@
1
+ import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
2
+
3
+ /** The symbols and words the plans views share: herdr's own status words, and the state of a phase. */
4
+ export const STATUS_SYMBOL: Record<AgentStatus, string> = {
5
+ [AGENT_STATUS.working]: '⚡',
6
+ [AGENT_STATUS.blocked]: '❓',
7
+ [AGENT_STATUS.idle]: '⏸',
8
+ [AGENT_STATUS.done]: '📨',
9
+ }
10
+ export const NO_AGENT_SYMBOL = '·'
11
+ export const NO_AGENT_WORD = 'no agent'
12
+ export const UNKNOWN_SYMBOL = '•'
13
+
14
+ export const statusSymbol = (agent: string | null): string => {
15
+ if (agent === null) return NO_AGENT_SYMBOL
16
+ return isAgentStatus(agent) ? STATUS_SYMBOL[agent] : UNKNOWN_SYMBOL
17
+ }
18
+
19
+ export const statusWord = (agent: string | null): string => agent ?? NO_AGENT_WORD
20
+
21
+ /** Before a row that opens and closes: lanes, the notes of a lane, the planned phases, the ticked items of a phase. */
22
+ export const CHEVRON = { closed: '▸', open: '▾' } as const
23
+
24
+ export const chevronOf = (open: boolean): string => (open ? CHEVRON.open : CHEVRON.closed)
25
+
26
+ export const PHASE_STATE = { missing: 'missing', empty: 'empty', all: 'all', open: 'open' } as const
27
+
28
+ export type PhaseState = (typeof PHASE_STATE)[keyof typeof PHASE_STATE]
29
+
30
+ /** A phase the plan lacks, one without checkboxes, one fully ticked, or one with ticks still to do. */
31
+ export const phaseStateOf = ({
32
+ title,
33
+ done,
34
+ total,
35
+ }: {
36
+ title: string | null
37
+ done: number
38
+ total: number
39
+ }): PhaseState => {
40
+ if (title === null) return PHASE_STATE.missing
41
+ if (total === 0) return PHASE_STATE.empty
42
+ return done === total ? PHASE_STATE.all : PHASE_STATE.open
43
+ }
44
+
45
+ /** What a phase shows in place of a symbol: `0/5`, `✓ 15/15`, `–` without checkboxes, `?` when the plan lacks it. */
46
+ export const PHASE_COUNT = { missing: '?', empty: '–', allTicked: '✓' } as const
47
+
48
+ export const phaseCountOf = (phase: { title: string | null; done: number; total: number }): string => {
49
+ const state = phaseStateOf(phase)
50
+ if (state === PHASE_STATE.missing) return PHASE_COUNT.missing
51
+ if (state === PHASE_STATE.empty) return PHASE_COUNT.empty
52
+ const count = `${phase.done}/${phase.total}`
53
+ return state === PHASE_STATE.all ? `${PHASE_COUNT.allTicked} ${count}` : count
54
+ }
@@ -0,0 +1,103 @@
1
+ import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
2
+ import { CHECKS, type ChecksState } from './pull-requests'
3
+ import { NO_AGENT_WORD, PHASE_COUNT } from './symbols'
4
+
5
+ /**
6
+ * Everything the plans view says in words but the watcher's badges. The view is English whatever language the
7
+ * project answers in; plan and phase titles come from the plans as written, and the status words are herdr's own terms.
8
+ */
9
+ export type PlanTexts = {
10
+ noAgent: string
11
+ noPlan: string
12
+ noWorktree: string
13
+ noRow: string
14
+ notInPlan: string
15
+ noCheckboxes: string
16
+ ticked: string
17
+ tickedUnmerged: string
18
+ tickedSummary: (count: number) => string
19
+ notesHidden: (count: number) => string
20
+ notesShown: (count: number) => string
21
+ draft: string
22
+ privateLink: string
23
+ planned: (phases: number, plans: number) => string
24
+ following: (groups: string) => string
25
+ checks: Record<ChecksState, string>
26
+ hint: string
27
+ legend: string[]
28
+ }
29
+
30
+ /**
31
+ * The words of the watcher's badges and of the header notice when they are missing, in the project's response
32
+ * language (D96): they report on the lane's pull request as the watcher's own messages do.
33
+ */
34
+ export type BadgeTexts = {
35
+ preview: string
36
+ /** The feedback since the last push, without ignored authors. */
37
+ feedback: (count: number) => string
38
+ changesRequested: string
39
+ watcherOff: string
40
+ stale: (minutes: number) => string
41
+ }
42
+
43
+ export const BADGE_TEXTS: Record<PlanLanguage, BadgeTexts> = {
44
+ [PLAN_LANGUAGE.de]: {
45
+ preview: '🌐 preview',
46
+ feedback: (count) => `💬 ${count} neu`,
47
+ changesRequested: '✋ Änderungen verlangt',
48
+ watcherOff: 'Watcher aus',
49
+ stale: (minutes) => `Stand ${minutes} min alt`,
50
+ },
51
+ [PLAN_LANGUAGE.en]: {
52
+ preview: '🌐 preview',
53
+ feedback: (count) => `💬 ${count} new`,
54
+ changesRequested: '✋ changes requested',
55
+ watcherOff: 'watcher off',
56
+ stale: (minutes) => `state ${minutes} min old`,
57
+ },
58
+ }
59
+
60
+ /** What the view says about a board, in the word the project calls it (`board.term`). */
61
+ export const boardTexts = (term: string) => ({
62
+ missing: (glob: string) => `no ${term} under ${glob}`,
63
+ laneNotListed: (lane: string) => `lane "${lane}" is not in the ${term}`,
64
+ pageOpened: (path: string) => `${term} page opened: ${path}`,
65
+ pageNotOpened: (path: string, reason: string) => `${term} page written to ${path} (not opened: ${reason})`,
66
+ pageNotWritten: (reason: string) => `${term} page not written: ${reason}`,
67
+ pageNotOpenedReason: (reason: string) => `${term} page: ${reason}`,
68
+ notReadable: (reason: string) => `${term} not readable: ${reason}`,
69
+ })
70
+
71
+ export const TEXTS: PlanTexts = {
72
+ noAgent: NO_AGENT_WORD,
73
+ noPlan: 'no plan',
74
+ noWorktree: 'no worktree',
75
+ noRow: 'no row in lanes',
76
+ notInPlan: 'not in the plan',
77
+ noCheckboxes: 'no checkboxes',
78
+ ticked: 'ticked',
79
+ tickedUnmerged: 'ticked, not merged',
80
+ tickedSummary: (count) => `${count} ticked · click or a lists them`,
81
+ notesHidden: (count) => `${count} ${count === 1 ? 'note' : 'notes'} hidden · click or n shows them`,
82
+ notesShown: (count) => `${count} ${count === 1 ? 'note' : 'notes'} shown · click or n hides them`,
83
+ draft: 'draft · ',
84
+ privateLink: 'private',
85
+ following: (groups) => `also open · ${groups}`,
86
+ planned: (phases, plans) =>
87
+ `planned (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
88
+ checks: {
89
+ [CHECKS.passed]: '✓ checks',
90
+ [CHECKS.failed]: '✗ checks',
91
+ [CHECKS.pending]: '… checks',
92
+ [CHECKS.none]: 'no checks',
93
+ },
94
+ hint: '↑↓ select →← open a all n notes s html q quit ? help',
95
+ legend: [
96
+ 'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a 📎 chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
97
+ '⚡ working · ❓ blocked, waits for you · 📨 done = the agent finished its answer, not the lane · ⏸ idle · · no agent · working and blocked lanes open by themselves, → ← overrule until the status changes',
98
+ `n/m = m checkboxes of the phase, n ticked (the phase being worked on is yellow; ticked, not merged = all ticked in the worktree, not on the base branch yet) · ${PHASE_COUNT.allTicked} n/n all ticked · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · n shows or hides the notes of a lane · s opens every lane and plan as one HTML page · ⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main`,
99
+ ],
100
+ }
101
+
102
+ /** The mark of a running lane the board has no row for; null for a lane it lists. */
103
+ export const laneMarkText = (lane: { unlisted?: boolean }): string | null => (lane.unlisted ? TEXTS.noRow : null)
@@ -0,0 +1,45 @@
1
+ import { isAbsolute, relative, sep } from 'node:path'
2
+ import type { SprintLane } from '../plan-format'
3
+ import type { LaneAgent } from '../terminal'
4
+
5
+ /** A worktree with an agent that has no row in the sprint table; `name` is its directory below the worktree directory. */
6
+ export type UnlistedLane = { name: string; agent: LaneAgent }
7
+
8
+ type PlacedAgent = { lane: string; agent: LaneAgent }
9
+
10
+ /** The worktree an agent runs in: the first directory below `worktreeDirectory`, null for anything outside it. */
11
+ export const laneDirectoryOf = (cwd: string, worktreeDirectory: string): string | null => {
12
+ const below = relative(worktreeDirectory, cwd)
13
+ if (below === '' || below === '..' || below.startsWith(`..${sep}`) || isAbsolute(below)) return null
14
+ return below.split(sep)[0]
15
+ }
16
+
17
+ /** An unlisted lane as a sprint lane: its name and nothing else, no port, no steps. */
18
+ export const unlistedSprintLane = (name: string): SprintLane => ({ name, port: null, steps: [], closed: false })
19
+
20
+ const placed = (agents: readonly LaneAgent[], worktreeDirectory: string): PlacedAgent[] =>
21
+ agents.flatMap((agent) => {
22
+ const lane = agent.cwd ? laneDirectoryOf(agent.cwd, worktreeDirectory) : null
23
+ return lane === null ? [] : [{ lane, agent }]
24
+ })
25
+
26
+ /**
27
+ * The lanes that run but are missing from the sprint table: one per worktree below `worktreeDirectory` with an
28
+ * agent in it. A worktree counts as listed when the table names it, or names any agent running in it. The first
29
+ * agent of a worktree stands for it; the lanes come in name order.
30
+ */
31
+ export const unlistedLanes = (
32
+ agents: readonly LaneAgent[],
33
+ listed: ReadonlySet<string>,
34
+ worktreeDirectory: string,
35
+ ): UnlistedLane[] => {
36
+ const inWorktrees = placed(agents, worktreeDirectory)
37
+ const listedWorktrees = new Set(
38
+ inWorktrees.filter(({ lane, agent }) => listed.has(lane) || listed.has(agent.name)).map(({ lane }) => lane),
39
+ )
40
+ const byLane = new Map<string, LaneAgent>()
41
+ for (const { lane, agent } of inWorktrees) {
42
+ if (!listedWorktrees.has(lane) && !byLane.has(lane)) byLane.set(lane, agent)
43
+ }
44
+ return [...byLane].sort(([left], [right]) => left.localeCompare(right)).map(([name, agent]) => ({ name, agent }))
45
+ }
@@ -0,0 +1,68 @@
1
+ import { fit, plainText } from '../terminal/text'
2
+ import { renderSelected, type Screen } from './layout'
3
+ import type { Options } from './options'
4
+ import { laneSelection, overviewSelection, type Selection } from './selection'
5
+ import type { Snapshot } from './snapshot'
6
+
7
+ const DEFAULT_WIDTH = 100
8
+ const CURSOR_HOME = '\x1b[H'
9
+ const CLEAR_LINE_END = '\x1b[K'
10
+ const CLEAR_BELOW = '\x1b[J'
11
+
12
+ export const TERMINAL_ON = '\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h'
13
+ export const TERMINAL_OFF = '\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l'
14
+
15
+ /** One column short of the pane: a line filling the last column makes some terminals wrap it. */
16
+ export const paneWidth = (): number => (process.stdout.columns ?? (Number(process.env.COLUMNS) || DEFAULT_WIDTH)) - 1
17
+
18
+ const paneRows = (): number => process.stdout.rows ?? Number.MAX_SAFE_INTEGER
19
+
20
+ export const bodyRows = (screen: Screen): number =>
21
+ Math.max(paneRows() - screen.header.length - screen.footer.length, 1)
22
+
23
+ export const clockOf = (locale: string): string => new Date().toLocaleTimeString(locale)
24
+
25
+ export const renderSnapshot = (
26
+ snapshot: Snapshot,
27
+ selection: Selection,
28
+ errors: string[],
29
+ width: number,
30
+ locale: string,
31
+ ): Screen => {
32
+ const screen = renderSelected(
33
+ {
34
+ title: snapshot.title,
35
+ time: clockOf(locale),
36
+ lanes: snapshot.views,
37
+ planned: snapshot.planned,
38
+ watch: snapshot.watch,
39
+ language: snapshot.language,
40
+ },
41
+ selection,
42
+ width,
43
+ )
44
+ return { ...screen, footer: [...errors.map((error) => fit(error, width)), ...screen.footer] }
45
+ }
46
+
47
+ export const startSelection = (snapshot: Snapshot, options: Options): Selection =>
48
+ options.lane
49
+ ? laneSelection(snapshot.views[0]?.progress, snapshot.views[0]?.status.agent ?? null)
50
+ : overviewSelection()
51
+
52
+ /** Header and footer stay put; the body shows `bodyRows` lines from `top`, padded so the footer sits at the bottom. */
53
+ export const draw = (screen: Screen, top: number): void => {
54
+ const width = paneWidth()
55
+ const rows = bodyRows(screen)
56
+ const body = screen.body.lines.slice(top, top + rows)
57
+ const padding = Array.from({ length: Math.max(rows - body.length, 0) }, () => '')
58
+ const lines = [...screen.header, ...body, ...padding, ...screen.footer]
59
+ process.stdout.write(
60
+ `${CURSOR_HOME}${lines.map((line) => `${fit(line, width)}${CLEAR_LINE_END}`).join('\r\n')}${CLEAR_BELOW}`,
61
+ )
62
+ }
63
+
64
+ /** One frame as text; ANSI styling is dropped when stdout is not a terminal. */
65
+ export const frameText = (screen: Screen, isTerminal: boolean): string => {
66
+ const lines = [...screen.header, ...screen.body.lines, ...screen.footer]
67
+ return (isTerminal ? lines : lines.map(plainText)).join('\n')
68
+ }
@@ -0,0 +1,53 @@
1
+ import {
2
+ type LaneProgress,
3
+ laneProgress,
4
+ PHASE_ID_PREFIX,
5
+ type PlanStep,
6
+ type PlansByHref,
7
+ type SprintLane,
8
+ } from '../plan-format'
9
+ import { belongsToLane } from './phase-source'
10
+
11
+ /**
12
+ * The phases of a plan the lane worked on in its worktree although none of its rows names them, in plan order: touched
13
+ * by `mergePlanPhases` and with at least one tick, so a draft the lane only wrote down does not become its current phase.
14
+ */
15
+ const workedIds = (lane: SprintLane, plans: PlansByHref, href: string): string[] => {
16
+ const named = lane.steps.filter((step) => step.planHref === href).flatMap((step) => step.phaseIds)
17
+ return (plans[href] ?? [])
18
+ .filter((phase) => phase.touched && phase.items.some((item) => item.done) && !belongsToLane(phase.id, named))
19
+ .map((phase) => phase.id)
20
+ }
21
+
22
+ /** A step no board row wrote: the plan of `last`, the worked phases, no model and no start condition. */
23
+ const workedStep = (last: PlanStep, ids: string[]): PlanStep => ({
24
+ planTitle: last.planTitle,
25
+ planHref: last.planHref,
26
+ phaseIds: ids,
27
+ label: ids.map((id) => `${PHASE_ID_PREFIX}${id}`).join(' '),
28
+ startsAfter: '',
29
+ model: '',
30
+ withoutRow: true,
31
+ })
32
+
33
+ /**
34
+ * The lane with one more step per plan for the phases it worked on without a board row: `mergePlanPhases` marks
35
+ * them touched (new in the worktree, or ticked further than the base). Marked `withoutRow`, they count as older phases
36
+ * when `currentPhaseOf` looks for the lane's newest board row, and they leave the list of phases still open in the plan.
37
+ */
38
+ export const withWorkedPhases = (lane: SprintLane, plans: PlansByHref): SprintLane => {
39
+ const hrefs = [...new Set(lane.steps.flatMap((step) => (step.planHref ? [step.planHref] : [])))]
40
+ const added = hrefs.flatMap((href) => {
41
+ const ids = workedIds(lane, plans, href)
42
+ const last = lane.steps.filter((step) => step.planHref === href).at(-1)
43
+ return last && ids.length > 0 ? [workedStep(last, ids)] : []
44
+ })
45
+ return added.length > 0 ? { ...lane, steps: [...lane.steps, ...added] } : lane
46
+ }
47
+
48
+ /**
49
+ * The progress of a lane over its rows and the phases it worked on without one, as the watcher reports it. The view
50
+ * builds the same from `withWorkedPhases` itself, since it also needs that lane for the phases still open.
51
+ */
52
+ export const workedLaneProgress = (lane: SprintLane, plans: PlansByHref): LaneProgress =>
53
+ laneProgress(withWorkedPhases(lane, plans), plans)
@@ -0,0 +1,13 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ export const checksum = (content: string): string => createHash('sha256').update(content, 'utf8').digest('hex')
4
+
5
+ const sortKeys = (value: unknown): unknown => {
6
+ if (Array.isArray(value)) return value.map(sortKeys)
7
+ if (value === null || typeof value !== 'object') return value
8
+ const entries = Object.entries(value).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
9
+ return Object.fromEntries(entries.map(([key, inner]) => [key, sortKeys(inner)]))
10
+ }
11
+
12
+ /** JSON with sorted keys, so two semantically equal objects serialise to the same string. */
13
+ export const canonicalJson = (value: unknown): string => JSON.stringify(sortKeys(value))
@@ -0,0 +1,14 @@
1
+ /** One answer of a `choose` question: the value the code gets and the words the user reads. */
2
+ export type Choice<T extends string> = { value: T; label: string }
3
+
4
+ /** The text of a `choose` question: the question, then one numbered line per answer, the fallback marked. */
5
+ export const choiceText = <T extends string>(question: string, options: readonly Choice<T>[], fallback: T): string =>
6
+ [
7
+ question,
8
+ ...options.map(({ value, label }, index) => ` ${index + 1}) ${label}${value === fallback ? ' (default)' : ''}`),
9
+ `Choose 1-${options.length}, Enter for the default: `,
10
+ ].join('\n')
11
+
12
+ /** The chosen value: a valid number picks its answer, anything else (Enter included) the fallback. */
13
+ export const chosenValue = <T extends string>(answer: string, options: readonly Choice<T>[], fallback: T): T =>
14
+ options[Number.parseInt(answer, 10) - 1]?.value ?? fallback
@@ -0,0 +1,113 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { homedir } from 'node:os'
3
+ import { createInterface } from 'node:readline/promises'
4
+ import { CLI_NAME } from '../version/release'
5
+ import { type Choice, choiceText, chosenValue } from './choice'
6
+ import { errorMessage } from './error-message'
7
+ import { linkHerdrPluginFrom } from './herdr-plugin'
8
+ import type { KitSource } from './kit'
9
+ import { type KitLocation, locateKit, readKitSource } from './kit-source'
10
+ import { exitCodeOf, formatReport, SYNC_EXIT } from './report'
11
+ import type { SyncResult } from './sync-result'
12
+ import { detectTools, INSTALLERS, type InstallerName, spawnTool, type ToolPresence } from './tools'
13
+
14
+ export type CommandContext = {
15
+ projectRoot: string
16
+ /** `XDG_CONFIG_HOME` from the environment; a test context leaves it out, so its home stays its own. */
17
+ configHome?: string
18
+ /** Where `--global` installs: the directory holding the user's `.claude/`. */
19
+ home: string
20
+ locate: () => Promise<KitLocation>
21
+ log: (line: string) => void
22
+ /** Hooks the herdr plugin in and returns the line to print, if any. Must not throw: a failure is a line, not an error. */
23
+ linkHerdrPlugin: (packageRoot: string) => string | null
24
+ /** A yes/no question to the user; null when there is no terminal to ask in. */
25
+ ask: (question: string) => Promise<boolean | null>
26
+ /** A choice among fixed answers; Enter takes `fallback`; null when there is no terminal to ask in. */
27
+ choose: <T extends string>(question: string, options: readonly Choice<T>[], fallback: T) => Promise<T | null>
28
+ /** Which external tools are installed; the test context answers a fixed value, so no test depends on the machine. */
29
+ detectTools: () => ToolPresence
30
+ /** True when `jh` runs from the PATH, so a hook may name it; the test context answers a fixed value. */
31
+ cliOnPath: () => boolean
32
+ /** Runs one of the kit's fixed installers with the terminal attached; the exit code. */
33
+ runInstaller: (name: InstallerName) => number
34
+ }
35
+
36
+ /** What a command did: lines to print before the report, and the sync result. */
37
+ export type CommandOutcome = { notes: string[]; result: SyncResult }
38
+
39
+ const YES = /^y(es)?$/i
40
+
41
+ const hasTerminal = (): boolean => Boolean(process.stdin.isTTY && process.stdout.isTTY)
42
+
43
+ /** One question on the terminal, the trimmed answer. */
44
+ const questionOnTerminal = async (text: string): Promise<string> => {
45
+ const terminal = createInterface({ input: process.stdin, output: process.stdout })
46
+ try {
47
+ return (await terminal.question(text)).trim()
48
+ } finally {
49
+ terminal.close()
50
+ }
51
+ }
52
+
53
+ /** Asks on the terminal; without one on both stdin and stdout nobody would see or answer the question. */
54
+ const askOnTerminal = async (question: string): Promise<boolean | null> =>
55
+ hasTerminal() ? YES.test(await questionOnTerminal(`${question} [y/N] `)) : null
56
+
57
+ const chooseOnTerminal = async <T extends string>(
58
+ question: string,
59
+ options: readonly Choice<T>[],
60
+ fallback: T,
61
+ ): Promise<T | null> =>
62
+ hasTerminal()
63
+ ? chosenValue(await questionOnTerminal(choiceText(question, options, fallback)), options, fallback)
64
+ : null
65
+
66
+ /** One of `INSTALLERS`, run by `sh` from the home directory with the terminal attached; the name type admits no other command. */
67
+ const runInstallerOnTerminal = (name: InstallerName): number =>
68
+ spawnSync('sh', ['-c', INSTALLERS[name]], { stdio: 'inherit', cwd: homedir() }).status ?? 1
69
+
70
+ export const defaultContext = (): CommandContext => ({
71
+ projectRoot: process.cwd(),
72
+ home: homedir(),
73
+ configHome: process.env.XDG_CONFIG_HOME,
74
+ locate: () => locateKit(),
75
+ log: (line) => console.log(line),
76
+ linkHerdrPlugin: linkHerdrPluginFrom,
77
+ ask: askOnTerminal,
78
+ choose: chooseOnTerminal,
79
+ detectTools: () => detectTools(process.env, process.platform, spawnTool(process.env)),
80
+ cliOnPath: () => !spawnTool(process.env)(CLI_NAME, ['--version']).missing,
81
+ runInstaller: runInstallerOnTerminal,
82
+ })
83
+
84
+ export const usageError = (context: CommandContext, message: string): number => {
85
+ context.log(message)
86
+ return SYNC_EXIT.error
87
+ }
88
+
89
+ /** Reads the kit, runs the command, prints the report; any thrown error becomes exit code 2. */
90
+ export const runWithKit = async (
91
+ context: CommandContext,
92
+ commandName: string,
93
+ action: (kit: KitSource) => Promise<CommandOutcome>,
94
+ ): Promise<number> => {
95
+ try {
96
+ const location = await context.locate()
97
+ const { notes, result } = await action(await readKitSource(location))
98
+ const herdrNote = context.linkHerdrPlugin(location.packageRoot)
99
+ for (const note of herdrNote ? [...notes, herdrNote] : notes) context.log(note)
100
+ context.log(formatReport(result))
101
+ return exitCodeOf(result)
102
+ } catch (err) {
103
+ context.log(`${CLI_NAME} ${commandName} failed: ${errorMessage(err)}`)
104
+ return SYNC_EXIT.error
105
+ }
106
+ }
107
+
108
+ /** Installs into `~/.claude/` instead of the project's `.claude/`. */
109
+ export const GLOBAL_FLAG = '--global'
110
+
111
+ /** Printed before a global report: its paths are relative to the home directory, not to the project. */
112
+ export const globalTargetNote = (home: string): string =>
113
+ `Installed into ~/.claude; the paths below are relative to ${home}.`