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,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
|
+
}
|