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.
Files changed (250) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1122 -3
  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 +63 -5
  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,159 @@
1
+ import { takeCells, textWidth } from './width'
2
+
3
+ export const ESC = '\x1b'
4
+ const SGR_PATTERN = `${ESC}\\[[0-9;]*m`
5
+ const LINK_TERMINATOR = `(?:${ESC}\\\\|\\x07)`
6
+ const LINK_START_PATTERN = `${ESC}\\]8;[^;${ESC}\\x07]*;[^${ESC}\\x07]+${LINK_TERMINATOR}`
7
+ const LINK_END_PATTERN = `${ESC}\\]8;;${LINK_TERMINATOR}`
8
+ const ANSI_PATTERN = `${SGR_PATTERN}|${LINK_START_PATTERN}|${LINK_END_PATTERN}`
9
+ const ANSI = new RegExp(ANSI_PATTERN, 'g')
10
+ const ANSI_TOKEN = new RegExp(`(${ANSI_PATTERN})`)
11
+ const LINK_START = new RegExp(`^${LINK_START_PATTERN}$`)
12
+ const LINK_END_TOKEN = new RegExp(`^${LINK_END_PATTERN}$`)
13
+ const LINK_CLOSE = `${ESC}]8;;${ESC}\\`
14
+ const LINK = new RegExp(
15
+ `${ESC}\\]8;[^;${ESC}\\x07]*;([^${ESC}\\x07]+)${LINK_TERMINATOR}([^]*?)${LINK_END_PATTERN}`,
16
+ 'g',
17
+ )
18
+ export const RESET = `${ESC}[0m`
19
+ const ELLIPSIS = '…'
20
+ const BAR_START = `${ESC}[27;32m`
21
+ const BAR_REST = `${ESC}[39;2m`
22
+ export const BAR_SEGMENT = new RegExp(`${ESC}\\[27;32m[^${ESC}]*${ESC}\\[39;2m[^${ESC}]*${ESC}\\[0m`, 'g')
23
+ export const INVERSE_ON = `${ESC}[7m`
24
+ /**
25
+ * A badge's background and text in 256 colours: grey (240) with white (255) for every link, amber (136) with dark
26
+ * text (16) for something new to read, dark red (124) with white for something that blocks a merge. Each sets its
27
+ * own background, so it reads the same on light and dark terminals.
28
+ */
29
+ export const BADGE_TONE = { grey: '48;5;240;38;5;255', amber: '48;5;136;38;5;16', red: '48;5;124;38;5;255' } as const
30
+
31
+ export type BadgeTone = (typeof BADGE_TONE)[keyof typeof BADGE_TONE]
32
+ const FAINT_ON = `${ESC}[2m`
33
+ const FAINT_OFF = `${ESC}[22m`
34
+ const BAR_WIDTH = 10
35
+
36
+ const style = (code: string) => (text: string) => `${ESC}[${code}m${text}${RESET}`
37
+ export const paint = {
38
+ bold: style('1'),
39
+ dim: style('2'),
40
+ red: style('31'),
41
+ green: style('32'),
42
+ yellow: style('33'),
43
+ magenta: style('35'),
44
+ cyan: style('36'),
45
+ inverse: style('7'),
46
+ }
47
+
48
+ export const stripAnsi = (text: string): string => text.replace(ANSI, '')
49
+
50
+ const isPrintableAscii = (text: string): boolean =>
51
+ [...text].every((character) => {
52
+ const code = character.charCodeAt(0)
53
+ return code >= 0x20 && code <= 0x7e
54
+ })
55
+
56
+ /**
57
+ * `text` as an OSC 8 hyperlink; a terminal without support shows the bare text. A URL with a byte outside printable
58
+ * ASCII (OSC 8 allows no other) could end the sequence early and write its own, so it yields no link at all.
59
+ */
60
+ export const link = (url: string, text: string): string =>
61
+ isPrintableAscii(url) ? `${ESC}]8;;${url}${ESC}\\${text}${LINK_CLOSE}` : text
62
+
63
+ /**
64
+ * `text` as a badge: a background of `tone` (grey unless given) with a cell of padding either side, the text linked
65
+ * to `url`, and `hint` after it, faint.
66
+ */
67
+ export const badge = (url: string, text: string, hint = '', tone: BadgeTone = BADGE_TONE.grey): string =>
68
+ `${ESC}[${tone}m ${link(url, text)}${hint ? `${FAINT_ON} ${hint}${FAINT_OFF}` : ''} ${RESET}`
69
+
70
+ /** A line for output that is not a terminal: styling dropped, each link written out as `text (url)`. */
71
+ export const plainText = (text: string): string =>
72
+ stripAnsi(text.replace(LINK, (_, url, inner) => `${stripAnsi(inner)} (${url})`))
73
+
74
+ /** The terminal cells a styled line takes: emoji count two. */
75
+ export const visibleLength = (text: string): number => textWidth(stripAnsi(text))
76
+
77
+ /** Cuts a styled line to `width` visible characters, ending in `…` when something was cut. */
78
+ export const fit = (line: string, width: number): string => {
79
+ if (visibleLength(line) <= width) return line
80
+ const tokens = line.split(ANSI_TOKEN)
81
+ let budget = width - 1
82
+ let result = ''
83
+ let linkOpen = false
84
+ for (const token of tokens) {
85
+ if (token.startsWith(ESC)) {
86
+ result += token
87
+ if (LINK_START.test(token)) linkOpen = true
88
+ else if (LINK_END_TOKEN.test(token)) linkOpen = false
89
+ continue
90
+ }
91
+ result += takeCells(token, Math.max(budget, 0))
92
+ budget -= textWidth(token)
93
+ }
94
+ return `${result}${linkOpen ? LINK_CLOSE : ''}${ELLIPSIS}${RESET}`
95
+ }
96
+
97
+ /** Left and right part on one line; when both do not fit, the left part is cut, never the right. */
98
+ export const spread = (left: string, right: string, width: number): string => {
99
+ if (!right) return fit(left, width)
100
+ const room = width - visibleLength(right) - 1
101
+ if (room < 1) return fit(right, width)
102
+ const shown = fit(left, room)
103
+ return `${shown}${' '.repeat(width - visibleLength(shown) - visibleLength(right))}${right}`
104
+ }
105
+
106
+ /** A progress bar that is full only when everything is ticked: 38 of 40 shows nine blocks, not ten. */
107
+ export const bar = (done: number, total: number, width = BAR_WIDTH): string => {
108
+ const complete = total > 0 && done === total
109
+ const filled = complete ? width : total === 0 ? 0 : Math.floor((done / total) * width)
110
+ return `${BAR_START}${'█'.repeat(filled)}${BAR_REST}${'░'.repeat(width - filled)}${RESET}`
111
+ }
112
+
113
+ const chunk = (word: string, width: number): string[] => {
114
+ const chunks: string[] = []
115
+ for (let rest = word; rest.length > 0; rest = rest.slice(chunks[chunks.length - 1].length)) {
116
+ chunks.push(takeCells(rest, width) || String.fromCodePoint(rest.codePointAt(0) ?? 0))
117
+ }
118
+ return chunks
119
+ }
120
+
121
+ /**
122
+ * Groups `cells` greedily into lines of at most `room` cells, `gap` between neighbours. A cell that does not
123
+ * fit beside the last one starts a line; `split` may break a cell that is wider than a line into several.
124
+ */
125
+ export const packGroups = (
126
+ cells: string[],
127
+ room: number,
128
+ gap: string,
129
+ split: (cell: string) => string[] = (cell) => [cell],
130
+ ): string[][] => {
131
+ const groups: string[][] = []
132
+ const widths: number[] = []
133
+ for (const cell of cells) {
134
+ const width = visibleLength(cell)
135
+ const last = groups.length - 1
136
+ if (last >= 0 && widths[last] + gap.length + width <= room) {
137
+ groups[last].push(cell)
138
+ widths[last] += gap.length + width
139
+ continue
140
+ }
141
+ for (const part of split(cell)) {
142
+ groups.push([part])
143
+ widths.push(visibleLength(part))
144
+ }
145
+ }
146
+ return groups
147
+ }
148
+
149
+ export const pack = (cells: string[], room: number, gap: string, split?: (cell: string) => string[]): string[] =>
150
+ packGroups(cells, room, gap, split).map((group) => group.join(gap))
151
+
152
+ /** Breaks plain text into lines of at most `width` characters, at spaces where it can. */
153
+ export const wrap = (text: string, width: number): string[] =>
154
+ pack(text.split(' '), width, ' ', (word) => chunk(word, width))
155
+
156
+ export const padVisible = (text: string, width: number): string =>
157
+ `${text}${' '.repeat(Math.max(width - visibleLength(text), 0))}`
158
+
159
+ export const indented = (lines: string[], indent: string): string[] => lines.map((line) => `${indent}${line}`)
@@ -0,0 +1,30 @@
1
+ /** The agent states a terminal adapter reports; adapters deliver them as plain strings. */
2
+ export const AGENT_STATUS = { working: 'working', blocked: 'blocked', done: 'done', idle: 'idle' } as const
3
+
4
+ export type AgentStatus = (typeof AGENT_STATUS)[keyof typeof AGENT_STATUS]
5
+
6
+ export const isAgentStatus = (value: string): value is AgentStatus =>
7
+ (Object.values(AGENT_STATUS) as string[]).includes(value)
8
+
9
+ /** A name to list the unnamed agent of one pane under. */
10
+ export type AdoptedName = { pane: string; name: string }
11
+
12
+ /**
13
+ * One agent as a terminal adapter reports it; `pane` identifies where its sidebar title is set, `session` is the
14
+ * id of its Claude Code session (the name of its log), null when the terminal does not say. `cwd` is the directory the agent runs in.
15
+ */
16
+ export type LaneAgent = { name: string; pane: string; status: string; session: string | null; cwd: string | null }
17
+
18
+ /**
19
+ * Where lane agents run. The plans view reads agent status and sets a short title per lane through
20
+ * this; the adapter `none` stands for a setup without a terminal multiplexer.
21
+ */
22
+ export type LaneStatusSource = {
23
+ /**
24
+ * Throws with the reason when the terminal cannot be asked, so the screen can say why lanes show no agent.
25
+ * An agent without a name is left out, except the one in `adopt.pane`, which is listed under `adopt.name`.
26
+ */
27
+ listAgents: (adopt?: AdoptedName) => LaneAgent[]
28
+ /** Sets, or with null clears, the title of a pane. Returns the error text, or null on success. */
29
+ setTitle: (pane: string, title: string | null) => string | null
30
+ }
@@ -0,0 +1,90 @@
1
+ const VARIATION_SELECTOR = 0xfe0f
2
+ const ZERO_WIDTH_JOINER = 0x200d
3
+ const COMBINING_MARKS: readonly [number, number][] = [[0x0300, 0x036f]]
4
+
5
+ /** Wide in East Asian Width, or emoji by default presentation: a terminal draws these in two cells. */
6
+ const WIDE: readonly [number, number][] = [
7
+ [0x1100, 0x115f],
8
+ [0x231a, 0x231b],
9
+ [0x23e9, 0x23ec],
10
+ [0x23f0, 0x23f0],
11
+ [0x23f3, 0x23f3],
12
+ [0x25fd, 0x25fe],
13
+ [0x2614, 0x2615],
14
+ [0x2648, 0x2653],
15
+ [0x267f, 0x267f],
16
+ [0x2693, 0x2693],
17
+ [0x26a1, 0x26a1],
18
+ [0x26aa, 0x26ab],
19
+ [0x26bd, 0x26be],
20
+ [0x26c4, 0x26c5],
21
+ [0x26ce, 0x26ce],
22
+ [0x26d4, 0x26d4],
23
+ [0x26ea, 0x26ea],
24
+ [0x26f2, 0x26f3],
25
+ [0x26f5, 0x26f5],
26
+ [0x26fa, 0x26fa],
27
+ [0x26fd, 0x26fd],
28
+ [0x2705, 0x2705],
29
+ [0x270a, 0x270b],
30
+ [0x2728, 0x2728],
31
+ [0x274c, 0x274c],
32
+ [0x274e, 0x274e],
33
+ [0x2753, 0x2755],
34
+ [0x2757, 0x2757],
35
+ [0x2795, 0x2797],
36
+ [0x27b0, 0x27b0],
37
+ [0x27bf, 0x27bf],
38
+ [0x2b1b, 0x2b1c],
39
+ [0x2b50, 0x2b50],
40
+ [0x2b55, 0x2b55],
41
+ [0x2e80, 0xa4cf],
42
+ [0xac00, 0xd7a3],
43
+ [0xf900, 0xfaff],
44
+ [0xfe30, 0xfe6f],
45
+ [0xff00, 0xff60],
46
+ [0xffe0, 0xffe6],
47
+ [0x1f300, 0x1f64f],
48
+ [0x1f680, 0x1f6ff],
49
+ [0x1f900, 0x1f9ff],
50
+ [0x20000, 0x3fffd],
51
+ ]
52
+
53
+ const within = (ranges: readonly [number, number][], codePoint: number): boolean =>
54
+ ranges.some(([from, to]) => codePoint >= from && codePoint <= to)
55
+
56
+ /**
57
+ * The terminal cells one code point takes: 0 for variation selectors, joiners and combining marks, 2 for
58
+ * wide characters and emoji in emoji presentation, 1 otherwise. A symbol that is emoji only with a
59
+ * variation selector (`⏸`) is one cell on its own; `textWidth` counts it as two when the selector follows.
60
+ */
61
+ export const cellWidth = (codePoint: number): number => {
62
+ if (codePoint === VARIATION_SELECTOR || codePoint === ZERO_WIDTH_JOINER) return 0
63
+ if (within(COMBINING_MARKS, codePoint)) return 0
64
+ return within(WIDE, codePoint) ? 2 : 1
65
+ }
66
+
67
+ const characterWidth = (character: string, next: string | undefined): number => {
68
+ const width = cellWidth(character.codePointAt(0) ?? 0)
69
+ return next?.codePointAt(0) === VARIATION_SELECTOR && width === 1 ? 2 : width
70
+ }
71
+
72
+ /** The cells a text takes; an emoji followed by a variation selector takes two. */
73
+ export const textWidth = (text: string): number => {
74
+ const characters = [...text]
75
+ return characters.reduce((sum, character, index) => sum + characterWidth(character, characters[index + 1]), 0)
76
+ }
77
+
78
+ /** The longest start of `text` that fits `width` cells; a wide character that would cross the edge stays out. */
79
+ export const takeCells = (text: string, width: number): string => {
80
+ const characters = [...text]
81
+ let used = 0
82
+ let count = 0
83
+ for (const [index, character] of characters.entries()) {
84
+ const next = characterWidth(character, characters[index + 1])
85
+ if (used + next > width) break
86
+ used += next
87
+ count = index + 1
88
+ }
89
+ return characters.slice(0, count).join('')
90
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `jh update`: installs the newest release with the package manager that holds the kit globally,
3
+ * then syncs `~/.claude` (and the project, when the working directory is inside one) with the new code. It
4
+ * runs only when the user or a session that asked the user starts it; no hook runs it.
5
+ *
6
+ * jh update
7
+ */
8
+ import { existsSync, realpathSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { findProjectRoot } from '../config/location'
12
+ import { defaultUpdateCheck, lockedVersion } from '../sync/if-changed'
13
+ import { locateKit } from '../sync/kit-source'
14
+ import { readInstalledVersion } from '../version/installed-version'
15
+ import { CLI_NAME } from '../version/release'
16
+ import { detectPackageManager, legacyPackageRoot } from './package-manager'
17
+ import { runUpdate, UPDATE_EXIT, type UpdateAdapters } from './update'
18
+
19
+ const USAGE = `Usage: ${CLI_NAME} update`
20
+
21
+ /** Runs `argv` without a shell and with the terminal attached, so the manager's own output stays visible. */
22
+ const runInherited = async (argv: readonly string[], cwd: string): Promise<number> => {
23
+ try {
24
+ const child = Bun.spawn([...argv], {
25
+ cwd: cwd ?? undefined,
26
+ stdin: 'inherit',
27
+ stdout: 'inherit',
28
+ stderr: 'inherit',
29
+ })
30
+ return await child.exited
31
+ } catch {
32
+ return UPDATE_EXIT.error
33
+ }
34
+ }
35
+
36
+ const adaptersFor = async (): Promise<UpdateAdapters> => {
37
+ const { version, packageRoot } = await locateKit()
38
+ const home = homedir()
39
+ return {
40
+ installedVersion: version,
41
+ manager: detectPackageManager(realpathSync(packageRoot)),
42
+ latestVersion: defaultUpdateCheck().latestVersion,
43
+ installedVersionOnDisk: () => readInstalledVersion(packageRoot),
44
+ kitCli: [process.execPath, join(packageRoot, 'src', 'cli.ts')],
45
+ run: runInherited,
46
+ hasGlobalKit: async () => (await lockedVersion(home)) !== null,
47
+ hasLegacyPackage: () => existsSync(legacyPackageRoot(realpathSync(packageRoot))),
48
+ home,
49
+ projectRoot: findProjectRoot(process.cwd(), home, existsSync),
50
+ log: (line) => console.log(line),
51
+ }
52
+ }
53
+
54
+ export const run = async (args: string[]): Promise<number> => {
55
+ if (args.length > 0) {
56
+ console.log(USAGE)
57
+ return UPDATE_EXIT.error
58
+ }
59
+ try {
60
+ return await runUpdate(await adaptersFor())
61
+ } catch (error) {
62
+ console.log(`${CLI_NAME} update failed: ${error instanceof Error ? error.message : String(error)}`)
63
+ return UPDATE_EXIT.error
64
+ }
65
+ }
@@ -0,0 +1,56 @@
1
+ import { dirname, join } from 'node:path'
2
+ import { isRelease, PACKAGE_NAME } from '../version/release'
3
+
4
+ export const PACKAGE_MANAGER = { bun: 'bun', npm: 'npm', pnpm: 'pnpm', yarn: 'yarn' } as const
5
+
6
+ export type PackageManager = (typeof PACKAGE_MANAGER)[keyof typeof PACKAGE_MANAGER]
7
+
8
+ /** The package's name before the rename; `jh update` names how to remove it, never removes it itself. */
9
+ export const LEGACY_PACKAGE_NAME = '@crossdelta/agent-kit'
10
+
11
+ /** Where each manager keeps its global packages; the first match decides, npm's `lib/node_modules` last. */
12
+ const GLOBAL_LOCATIONS: readonly (readonly [RegExp, PackageManager])[] = [
13
+ [/\/\.bun\/install\/global\//, PACKAGE_MANAGER.bun],
14
+ [/\/pnpm\/global\/|\/\.pnpm-global\//, PACKAGE_MANAGER.pnpm],
15
+ [/\/yarn\/global\/|\/\.yarn\/global\//, PACKAGE_MANAGER.yarn],
16
+ [/\/lib\/node_modules\//, PACKAGE_MANAGER.npm],
17
+ ]
18
+
19
+ /**
20
+ * The manager that installed the kit globally, read from where the package lies; null for a project
21
+ * dependency or a source checkout, which `jh update` does not touch.
22
+ */
23
+ export const detectPackageManager = (packageRoot: string): PackageManager | null => {
24
+ const path = `${packageRoot.replaceAll('\\', '/')}/`
25
+ return GLOBAL_LOCATIONS.find(([pattern]) => pattern.test(path))?.[1] ?? null
26
+ }
27
+
28
+ /**
29
+ * The argv, never a shell line, that installs `version` globally without running install scripts: the kit
30
+ * has none and a dependency's would run before the new code is looked at. Throws unless `version` is a plain release.
31
+ */
32
+ export const installCommand = (manager: PackageManager, version: string): string[] => {
33
+ if (!isRelease(version)) throw new Error(`Refusing to install "${version}": not a plain release version`)
34
+ const spec = `${PACKAGE_NAME}@${version}`
35
+ const verbs: Record<PackageManager, string[]> = {
36
+ [PACKAGE_MANAGER.bun]: ['bun', 'add', '-g'],
37
+ [PACKAGE_MANAGER.npm]: ['npm', 'install', '-g'],
38
+ [PACKAGE_MANAGER.pnpm]: ['pnpm', 'add', '-g'],
39
+ [PACKAGE_MANAGER.yarn]: ['yarn', 'global', 'add'],
40
+ }
41
+ return [...verbs[manager], '--ignore-scripts', spec]
42
+ }
43
+
44
+ /** The argv that removes the global package `name` with `manager`. */
45
+ export const removeCommand = (manager: PackageManager, name: string): string[] => {
46
+ const verbs: Record<PackageManager, string[]> = {
47
+ [PACKAGE_MANAGER.bun]: ['bun', 'remove', '-g'],
48
+ [PACKAGE_MANAGER.npm]: ['npm', 'uninstall', '-g'],
49
+ [PACKAGE_MANAGER.pnpm]: ['pnpm', 'remove', '-g'],
50
+ [PACKAGE_MANAGER.yarn]: ['yarn', 'global', 'remove'],
51
+ }
52
+ return [...verbs[manager], name]
53
+ }
54
+
55
+ /** Where the package before the rename lies when the same manager installed it: beside this one. */
56
+ export const legacyPackageRoot = (packageRoot: string): string => join(dirname(packageRoot), LEGACY_PACKAGE_NAME)
@@ -0,0 +1,105 @@
1
+ import { CLI_NAME, isNewerVersion, isRelease } from '../version/release'
2
+ import { installCommand, LEGACY_PACKAGE_NAME, type PackageManager, removeCommand } from './package-manager'
3
+
4
+ export const UPDATE_EXIT = { ok: 0, error: 1 } as const
5
+
6
+ /** Everything `jh update` touches outside itself, so a test runs it without a registry or an install. */
7
+ export type UpdateAdapters = {
8
+ installedVersion: string
9
+ /** The manager that installed the kit globally, or null when it is not a global install. */
10
+ manager: PackageManager | null
11
+ latestVersion: () => Promise<string | null>
12
+ /** The version in the package on disk now, after the install; null when it cannot be read. */
13
+ installedVersionOnDisk: () => string | null
14
+ /** The words that start this very package's CLI (runtime, then `src/cli.ts`), so a sync runs the code just installed and no `jh` found on the path. */
15
+ kitCli: readonly string[]
16
+ /** Runs one command without a shell, in `cwd`, and returns its exit code. */
17
+ run: (argv: readonly string[], cwd: string) => Promise<number>
18
+ hasGlobalKit: () => Promise<boolean>
19
+ /** True when the package before the rename is installed globally beside this one. */
20
+ hasLegacyPackage: () => boolean
21
+ home: string
22
+ /** The project the working directory belongs to, or null. */
23
+ projectRoot: string | null
24
+ log: (line: string) => void
25
+ }
26
+
27
+ const notGlobalLine = (installed: string): string =>
28
+ `${CLI_NAME} ${installed} is not a global install (a project dependency or a source checkout), so \`${CLI_NAME} update\` leaves it alone. Update it with the package manager of that project.`
29
+
30
+ const failed = (log: UpdateAdapters['log'], step: string, command: string): number => {
31
+ log(`${CLI_NAME} update: ${step} failed. Run it by hand: ${command}`)
32
+ return UPDATE_EXIT.error
33
+ }
34
+
35
+ /** The syncs that bring `~/.claude` and the project to the new files, in the order they run. */
36
+ const syncSteps = async (adapters: UpdateAdapters): Promise<{ argv: string[]; cwd: string }[]> => [
37
+ ...((await adapters.hasGlobalKit()) ? [{ argv: [...adapters.kitCli, 'sync', '--global'], cwd: adapters.home }] : []),
38
+ ...(adapters.projectRoot ? [{ argv: [...adapters.kitCli, 'sync'], cwd: adapters.projectRoot }] : []),
39
+ ]
40
+
41
+ /**
42
+ * Installs `version` from the home directory, so the working directory's package-manager configuration cannot
43
+ * point the install at another registry, and checks the package on disk is that version afterwards.
44
+ */
45
+ const installAndVerify = async (
46
+ adapters: UpdateAdapters,
47
+ manager: PackageManager,
48
+ version: string,
49
+ ): Promise<number> => {
50
+ const install = installCommand(manager, version)
51
+ if ((await adapters.run(install, adapters.home)) !== 0) return failed(adapters.log, 'the install', install.join(' '))
52
+ const onDisk = adapters.installedVersionOnDisk()
53
+ if (onDisk === version) return UPDATE_EXIT.ok
54
+ adapters.log(
55
+ `${CLI_NAME} update: the install ran, but the version on disk is still ${onDisk ?? 'unreadable'}, not ${version}.`,
56
+ )
57
+ return UPDATE_EXIT.error
58
+ }
59
+
60
+ /**
61
+ * The update itself: installs the newest release, then runs the syncs with the freshly installed code. Does
62
+ * nothing when no newer release exists. The version it
63
+ * installs is the one it checked, and only a plain release reaches the command line.
64
+ */
65
+ const updateToLatest = async (adapters: UpdateAdapters, manager: PackageManager): Promise<number> => {
66
+ const { installedVersion, log } = adapters
67
+ const latest = await adapters.latestVersion()
68
+ if (latest === null) {
69
+ log(`${CLI_NAME} update: the npm registry did not answer, nothing was changed.`)
70
+ return UPDATE_EXIT.error
71
+ }
72
+ if (!isRelease(latest)) {
73
+ log(`${CLI_NAME} update: the npm registry answered no plain release version, nothing was changed.`)
74
+ return UPDATE_EXIT.error
75
+ }
76
+ if (!isNewerVersion(latest, installedVersion)) {
77
+ log(`${CLI_NAME} ${installedVersion} is the latest release.`)
78
+ return UPDATE_EXIT.ok
79
+ }
80
+ const installed = await installAndVerify(adapters, manager, latest)
81
+ if (installed !== UPDATE_EXIT.ok) return installed
82
+ for (const { argv, cwd } of await syncSteps(adapters)) {
83
+ if ((await adapters.run(argv, cwd)) !== 0) return failed(log, 'the sync', argv.join(' '))
84
+ }
85
+ log(`${CLI_NAME} updated ${installedVersion} → ${latest}.`)
86
+ return UPDATE_EXIT.ok
87
+ }
88
+
89
+ const legacyPackageLine = (manager: PackageManager): string =>
90
+ `${LEGACY_PACKAGE_NAME} is still installed globally; ${CLI_NAME} replaces it. Remove it with: ${removeCommand(manager, LEGACY_PACKAGE_NAME).join(' ')}`
91
+
92
+ /**
93
+ * Installs the newest release with the manager that holds the kit globally, then syncs `~/.claude` and the
94
+ * project with the freshly installed code; a global package from before the rename is named, not removed.
95
+ */
96
+ export const runUpdate = async (adapters: UpdateAdapters): Promise<number> => {
97
+ const { manager, log } = adapters
98
+ if (!manager) {
99
+ log(notGlobalLine(adapters.installedVersion))
100
+ return UPDATE_EXIT.error
101
+ }
102
+ const exitCode = await updateToLatest(adapters, manager)
103
+ if (exitCode === UPDATE_EXIT.ok && adapters.hasLegacyPackage()) log(legacyPackageLine(manager))
104
+ return exitCode
105
+ }
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isRelease } from './release'
4
+ import type { StartedKit } from './started-kit'
5
+
6
+ export type KitUpdate = { from: string; to: string }
7
+
8
+ /** How often a long-running process looks at the package on disk. */
9
+ export const VERSION_POLL_MS = 10_000
10
+
11
+ /**
12
+ * The version in the package at `packageRoot` now; null when it cannot be read or is no plain release.
13
+ * A reinstall replaces the directory, so a miss during it is normal and means "ask again later".
14
+ */
15
+ export const readInstalledVersion = (packageRoot: string): string | null => {
16
+ try {
17
+ const { version } = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as { version?: unknown }
18
+ return typeof version === 'string' && isRelease(version) ? version : null
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ /** The update a running process has not loaded yet: the version on disk differs from the one it started with. */
25
+ export const kitUpdate = (startedWith: string, onDisk: string | null): KitUpdate | null =>
26
+ onDisk !== null && onDisk !== startedWith ? { from: startedWith, to: onDisk } : null
27
+
28
+ /** The update a process has not loaded yet, read from the disk now; null without a located kit. */
29
+ export const pendingKitUpdate = (kit: StartedKit | null): KitUpdate | null =>
30
+ kit ? kitUpdate(kit.version, readInstalledVersion(kit.packageRoot)) : null
31
+
32
+ export const kitUpdatedLine = ({ from, to }: KitUpdate): string => `=== kit updated ${from} → ${to}`
@@ -0,0 +1,22 @@
1
+ export const PACKAGE_NAME = 'jackherd'
2
+
3
+ /** The command the package installs; every usage text and message names it through this. */
4
+ export const CLI_NAME = 'jh'
5
+
6
+ const RELEASE = /^(\d+)\.(\d+)\.(\d+)$/
7
+
8
+ const releaseParts = (version: string): number[] | null => {
9
+ const match = RELEASE.exec(version)
10
+ return match ? match.slice(1).map(Number) : null
11
+ }
12
+
13
+ /** True when `version` is a plain `major.minor.patch` release, the only shape that may reach a command line or a prompt. */
14
+ export const isRelease = (version: string): boolean => RELEASE.test(version)
15
+
16
+ /** True when `candidate` is a release (no pre-release) above `installed`. */
17
+ export const isNewerVersion = (candidate: string, installed: string): boolean => {
18
+ const [next, current] = [releaseParts(candidate), releaseParts(installed)]
19
+ if (!next || !current) return false
20
+ const index = next.findIndex((part, position) => part !== current[position])
21
+ return index !== -1 && next[index] > current[index]
22
+ }
@@ -0,0 +1,63 @@
1
+ /** What the watcher can say about the orchestrate skill after a kit update. */
2
+ export const SKILL_CHANGE = { changed: 'changed', unchanged: 'unchanged', unavailable: 'unavailable' } as const
3
+
4
+ export type SkillChange =
5
+ | { kind: typeof SKILL_CHANGE.changed; sections: string[]; diffPath: string }
6
+ | { kind: typeof SKILL_CHANGE.unchanged }
7
+ | { kind: typeof SKILL_CHANGE.unavailable; reason: string }
8
+
9
+ const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
10
+ const SECTION_HEADING = /^## (.+)$/
11
+ const NUMBERED_SECTION = /^(\d+)\.\s/
12
+ const HEAD = 'head'
13
+
14
+ /**
15
+ * The lines of the new text that changed, 1-based: an added line where it stands, a removed line at the place it
16
+ * left. Context lines only move the count.
17
+ */
18
+ const changedLines = (diff: string): number[] => {
19
+ const lines: number[] = []
20
+ let next = 0
21
+ for (const line of diff.split('\n')) {
22
+ const header = line.match(HUNK_HEADER)
23
+ if (header) {
24
+ next = Number(header[1])
25
+ continue
26
+ }
27
+ if (next === 0 || line.startsWith('\\')) continue
28
+ if (line.startsWith('-')) lines.push(next)
29
+ else if (line.startsWith('+')) lines.push(next++)
30
+ else next++
31
+ }
32
+ return lines
33
+ }
34
+
35
+ const sectionLabel = (heading: string): string => {
36
+ const numbered = heading.match(NUMBERED_SECTION)
37
+ return numbered ? `§ ${numbered[1]}` : heading.trim()
38
+ }
39
+
40
+ /** For each line of `text`, the `## ` section it belongs to; `head` before the first one. */
41
+ const sectionOfLines = (text: string): string[] => {
42
+ let current = HEAD
43
+ return text.split('\n').map((line) => {
44
+ const heading = line.match(SECTION_HEADING)
45
+ if (heading?.[1]) current = sectionLabel(heading[1])
46
+ return current
47
+ })
48
+ }
49
+
50
+ /** The sections of the new skill text whose lines a unified diff adds or removes, once each, in order. */
51
+ export const changedSections = (text: string, diff: string): string[] => {
52
+ const sections = sectionOfLines(text)
53
+ const touched = changedLines(diff).map((line) => sections[Math.min(line, sections.length) - 1] ?? HEAD)
54
+ return [...new Set(touched)]
55
+ }
56
+
57
+ export const skillChangeLine = (change: SkillChange): string => {
58
+ if (change.kind === SKILL_CHANGE.changed) {
59
+ return `orchestrate skill: ${change.sections.join(', ')} changed · diff ${change.diffPath}`
60
+ }
61
+ if (change.kind === SKILL_CHANGE.unchanged) return 'orchestrate skill unchanged'
62
+ return `orchestrate skill diff unavailable (${change.reason}): read the skill again`
63
+ }
@@ -0,0 +1,14 @@
1
+ import { locateKit } from '../sync/kit-source'
2
+ import { isRelease } from './release'
3
+
4
+ export type StartedKit = { packageRoot: string; version: string }
5
+
6
+ /** The kit this process loaded: where it lies and which version; null when the package cannot be located or its version is no plain release. */
7
+ export const startedKit = async (): Promise<StartedKit | null> => {
8
+ try {
9
+ const { packageRoot, version } = await locateKit()
10
+ return isRelease(version) ? { packageRoot, version } : null
11
+ } catch {
12
+ return null
13
+ }
14
+ }