jackherd 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/INSTALL.md +1 -1
- package/README.md +50 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
- package/dist/config.d.ts +39 -4
- package/dist/config.js +67 -26
- package/dist/plan-format.d.ts +45 -6
- package/dist/plan-format.js +46 -6
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +59 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +1 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +58 -24
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +7 -4
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +7 -2
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +29 -7
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +262 -68
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +107 -20
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +98 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +85 -62
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +23 -6
- package/src/plans/view.ts +17 -9
- package/src/plans/waiting-click.ts +192 -37
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +8 -3
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +16 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/sources.ts +6 -3
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { CLAUDE_DIRECTORY, claudePath, SKILLS_DIRECTORY } from '../claude/paths'
|
|
4
|
+
import { projectLockCandidates } from '../config/legacy-names'
|
|
5
|
+
import { parseManifest } from '../sync/manifest'
|
|
6
|
+
import { matchesGlob } from './glob'
|
|
7
|
+
import { CHECK_STATUS, type CheckResult, type CheckStatus } from './result'
|
|
8
|
+
import type { SkillBudgets } from './schema'
|
|
9
|
+
import {
|
|
10
|
+
KIT_FILE_FALLBACK_BUDGET,
|
|
11
|
+
KIT_SKILL_BUDGETS,
|
|
12
|
+
OWN_SKILL_BUDGET,
|
|
13
|
+
SUPPLEMENT_BUDGET,
|
|
14
|
+
} from './skill-budget-defaults'
|
|
15
|
+
|
|
16
|
+
/** Words per path or glob from the project root; a key joining paths with ` + ` budgets them together. */
|
|
17
|
+
export type BudgetTable = Readonly<SkillBudgets>
|
|
18
|
+
|
|
19
|
+
/** A file with the budget it falls back on when neither the project nor the kit names one. */
|
|
20
|
+
export type FileWords = { path: string; words: number; fallback: number }
|
|
21
|
+
|
|
22
|
+
export type Measurement = { label: string; words: number; budget: number }
|
|
23
|
+
|
|
24
|
+
type Overrun = typeof CHECK_STATUS.warn | typeof CHECK_STATUS.fail
|
|
25
|
+
|
|
26
|
+
type MeasuredOverrun = { entry: Measurement; overrun: Overrun }
|
|
27
|
+
|
|
28
|
+
const SUPPLEMENTS_DIRECTORY = 'project'
|
|
29
|
+
const KIT_DOCUMENT = /^(skills\/.+\.md|agents\/[^/]+\.md|rules\/kit\/[^/]+\.md)$/
|
|
30
|
+
const KIT_RULE = /^rules\//
|
|
31
|
+
const PATHS_IN_FRONTMATTER = /^---\r?\n(?:(?!---)[^\n]*\n)*?paths:/
|
|
32
|
+
const SUM_SEPARATOR = ' + '
|
|
33
|
+
const FAIL_FACTOR = 1.1
|
|
34
|
+
|
|
35
|
+
const OVERRUN_LABEL: Record<Overrun, string> = { warn: 'warning', fail: 'error' }
|
|
36
|
+
|
|
37
|
+
const isGlob = (key: string): boolean => /[*?]/.test(key)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The words of a file as `wc -w` counts them: runs of non-whitespace, over the whole file. The frontmatter counts,
|
|
41
|
+
* because an agent reads it with the rest, and the skill's description in it is loaded before the body is.
|
|
42
|
+
*/
|
|
43
|
+
export const countWords = (text: string): number => text.split(/\s+/).filter(Boolean).length
|
|
44
|
+
|
|
45
|
+
/** The paths a budget key names: one, or several joined with ` + ` for what loads together. */
|
|
46
|
+
export const budgetKeyPaths = (key: string): string[] => key.split(SUM_SEPARATOR)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a kit file, by its path in the kit (`skills/…`, `agents/…`, `rules/kit/…`), is text an agent loads as it
|
|
50
|
+
* is and so counts against a budget: a skill file, an agent, or a kit rule without a `paths:` glob in its
|
|
51
|
+
* frontmatter; a rule with one loads only on demand. The one choice `jh check` and the kit's own test share.
|
|
52
|
+
*/
|
|
53
|
+
export const isBudgetedKitDocument = (kitPath: string, text: string): boolean =>
|
|
54
|
+
KIT_DOCUMENT.test(kitPath) && !(KIT_RULE.test(kitPath) && PATHS_IN_FRONTMATTER.test(text))
|
|
55
|
+
|
|
56
|
+
/** A file warns above its budget and fails above the budget plus 10 %. */
|
|
57
|
+
export const overrunOf = (words: number, budget: number): Overrun | null => {
|
|
58
|
+
if (words > budget * FAIL_FACTOR) return CHECK_STATUS.fail
|
|
59
|
+
return words > budget ? CHECK_STATUS.warn : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The project's entry for the exact path, else its first glob that matches, else the kit's default, else `fallback`. */
|
|
63
|
+
export const budgetOf = (path: string, project: BudgetTable, defaults: BudgetTable, fallback: number): number => {
|
|
64
|
+
const glob = Object.keys(project).find((key) => isGlob(key) && matchesGlob(path, key))
|
|
65
|
+
return project[path] ?? (glob === undefined ? undefined : project[glob]) ?? defaults[path] ?? fallback
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sumEntry = (key: string, files: readonly FileWords[]): number | null => {
|
|
69
|
+
const parts = budgetKeyPaths(key).map((part) => files.find((file) => file.path === part))
|
|
70
|
+
return parts.every((part) => part !== undefined) ? parts.reduce((total, part) => total + part.words, 0) : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every file against its budget, then every sum entry of the project and the kit whose files all exist. */
|
|
74
|
+
export const measureFiles = (
|
|
75
|
+
files: readonly FileWords[],
|
|
76
|
+
project: BudgetTable,
|
|
77
|
+
defaults: BudgetTable,
|
|
78
|
+
): Measurement[] => {
|
|
79
|
+
const single = files.map(({ path, words, fallback }) => ({
|
|
80
|
+
label: path,
|
|
81
|
+
words,
|
|
82
|
+
budget: budgetOf(path, project, defaults, fallback),
|
|
83
|
+
}))
|
|
84
|
+
const sums = [...new Set([...Object.keys(project), ...Object.keys(defaults)])]
|
|
85
|
+
.filter((key) => key.includes(SUM_SEPARATOR))
|
|
86
|
+
.flatMap((key) => {
|
|
87
|
+
const words = sumEntry(key, files)
|
|
88
|
+
return words === null ? [] : [{ label: key, words, budget: budgetOf(key, project, defaults, 0) }]
|
|
89
|
+
})
|
|
90
|
+
return [...single, ...sums]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const overrunLine = ({ entry: { label, words, budget }, overrun }: MeasuredOverrun): string =>
|
|
94
|
+
`${OVERRUN_LABEL[overrun]}: ${label} has ${words} words, budget ${budget}`
|
|
95
|
+
|
|
96
|
+
const withinLine = (count: number): string =>
|
|
97
|
+
count === 1 ? '1 file within its word budget' : `${count} files within their word budget`
|
|
98
|
+
|
|
99
|
+
const statusOf = (overruns: readonly MeasuredOverrun[]): CheckStatus => {
|
|
100
|
+
if (overruns.some(({ overrun }) => overrun === CHECK_STATUS.fail)) return CHECK_STATUS.fail
|
|
101
|
+
return overruns.length > 0 ? CHECK_STATUS.warn : CHECK_STATUS.pass
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One line per overrun; one line in total when none is over. Warns above a budget, fails above the budget plus 10 %. */
|
|
105
|
+
export const budgetResult = (measured: readonly Measurement[], durationMs: number): CheckResult => {
|
|
106
|
+
const overruns = measured.flatMap((entry) => {
|
|
107
|
+
const overrun = overrunOf(entry.words, entry.budget)
|
|
108
|
+
return overrun === null ? [] : [{ entry, overrun }]
|
|
109
|
+
})
|
|
110
|
+
return {
|
|
111
|
+
name: 'Skill budgets',
|
|
112
|
+
command: `${measured.length} measured, words per budget`,
|
|
113
|
+
status: statusOf(overruns),
|
|
114
|
+
detail: overruns.map(overrunLine).join('\n') || withinLine(measured.length),
|
|
115
|
+
durationMs,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const readText = (path: string): string | null => {
|
|
120
|
+
try {
|
|
121
|
+
return readFileSync(path, 'utf8')
|
|
122
|
+
} catch {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const parseJson = (text: string): unknown => {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(text)
|
|
130
|
+
} catch {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The kit documents the first valid lock file lists, the lock under its old name counting while sync has not moved it. */
|
|
136
|
+
const kitPathsOf = (root: string): string[] => {
|
|
137
|
+
for (const candidate of projectLockCandidates(root)) {
|
|
138
|
+
const text = readText(candidate)
|
|
139
|
+
const parsed = text === null ? null : parseManifest(parseJson(text))
|
|
140
|
+
if (parsed?.ok) return Object.keys(parsed.manifest.files).filter((key) => KIT_DOCUMENT.test(key))
|
|
141
|
+
}
|
|
142
|
+
return []
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const listDirectory = (path: string): string[] => (existsSync(path) ? readdirSync(path) : [])
|
|
146
|
+
|
|
147
|
+
const fileWordsOf = (root: string, path: string, fallback: number): FileWords[] => {
|
|
148
|
+
const text = readText(join(root, path))
|
|
149
|
+
return text === null ? [] : [{ path, words: countWords(text), fallback }]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const kitFileWordsOf = (root: string, kitPath: string): FileWords[] => {
|
|
153
|
+
const path = claudePath(kitPath)
|
|
154
|
+
const text = readText(join(root, path))
|
|
155
|
+
return text === null || !isBudgetedKitDocument(kitPath, text)
|
|
156
|
+
? []
|
|
157
|
+
: [{ path, words: countWords(text), fallback: KIT_FILE_FALLBACK_BUDGET }]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const ownSkillPaths = (root: string, kit: ReadonlySet<string>): string[] =>
|
|
161
|
+
listDirectory(join(root, CLAUDE_DIRECTORY, SKILLS_DIRECTORY))
|
|
162
|
+
.map((name) => claudePath(SKILLS_DIRECTORY, name, 'SKILL.md'))
|
|
163
|
+
.filter((path) => !kit.has(path))
|
|
164
|
+
|
|
165
|
+
const supplementPaths = (root: string): string[] =>
|
|
166
|
+
listDirectory(join(root, CLAUDE_DIRECTORY, SUPPLEMENTS_DIRECTORY))
|
|
167
|
+
.filter((name) => name.endsWith('.md'))
|
|
168
|
+
.map((name) => claudePath(SUPPLEMENTS_DIRECTORY, name))
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The installed kit skills with their references, kit agents and kit rules that load every session (the lock file
|
|
172
|
+
* says which files the kit installed), the project's supplements and its own skills, measured against the budgets.
|
|
173
|
+
* Null when there is nothing to measure.
|
|
174
|
+
*/
|
|
175
|
+
export const skillBudgetCheck = (root: string, project: BudgetTable = {}): CheckResult | null => {
|
|
176
|
+
const started = Date.now()
|
|
177
|
+
const kitPaths = kitPathsOf(root)
|
|
178
|
+
const kit = new Set(kitPaths.map((key) => claudePath(key)))
|
|
179
|
+
const files = [
|
|
180
|
+
...kitPaths.flatMap((key) => kitFileWordsOf(root, key)),
|
|
181
|
+
...supplementPaths(root).flatMap((path) => fileWordsOf(root, path, SUPPLEMENT_BUDGET)),
|
|
182
|
+
...ownSkillPaths(root, kit).flatMap((path) => fileWordsOf(root, path, OWN_SKILL_BUDGET)),
|
|
183
|
+
]
|
|
184
|
+
return files.length === 0 ? null : budgetResult(measureFiles(files, project, KIT_SKILL_BUDGETS), Date.now() - started)
|
|
185
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const COMMANDS: CommandTable = {
|
|
|
24
24
|
[HERD_COMMAND.name]: () => import('./plans/command'),
|
|
25
25
|
init: () => import('./sync/init-command'),
|
|
26
26
|
[HERD_COMMAND.alias]: () => import('./plans/command'),
|
|
27
|
+
lane: () => import('./lane/command'),
|
|
27
28
|
preview: () => import('./preview/command'),
|
|
28
29
|
settled: () => import('./settled/command'),
|
|
29
30
|
sync: () => import('./sync/command'),
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { DAY_MS } from '../plan-format/clock'
|
|
2
|
+
|
|
3
|
+
/** The longest window `watch.parallelTasks.auto` may span: a forgotten switch ends within a week. */
|
|
4
|
+
export const MAX_AUTO_WINDOW_DAYS = 7
|
|
5
|
+
|
|
6
|
+
/** The days from `since` to `until`, two `YYYY-MM-DD` dates; read as UTC midnights, so no clock change shifts them. */
|
|
7
|
+
export const windowDays = (since: string, until: string): number =>
|
|
8
|
+
Math.round((Date.parse(`${until}T00:00:00Z`) - Date.parse(`${since}T00:00:00Z`)) / DAY_MS)
|
package/src/config/link-kinds.ts
CHANGED
|
@@ -8,11 +8,17 @@ export type LinkKind = {
|
|
|
8
8
|
icon: string
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export const ARTIFACT_LINK_KIND: LinkKind = {
|
|
12
|
+
pattern: '^https://claude\\.ai/(code/)?artifact/',
|
|
13
|
+
label: 'Artifact',
|
|
14
|
+
icon: '✦',
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
/** The kinds known without configuration: plain URL patterns, no service is called. */
|
|
12
18
|
export const DEFAULT_LINK_KINDS: readonly LinkKind[] = [
|
|
13
19
|
{ pattern: '^https://github\\.com/[^/]+/[^/]+/(pull|issues)/\\d+', label: 'GitHub', icon: '⑂' },
|
|
14
20
|
{ pattern: '^https://(www\\.)?figma\\.com/', label: 'Figma', icon: '◈' },
|
|
15
|
-
|
|
21
|
+
ARTIFACT_LINK_KIND,
|
|
16
22
|
]
|
|
17
23
|
|
|
18
24
|
export const isRegularExpression = (source: string): boolean => {
|
package/src/config/schema.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { PLAN_LANGUAGE, RESPONSE_ADDRESS } from '../vocabulary'
|
|
|
5
5
|
import { CONTENT_FIELDS, SOURCE_FIELD } from '../watch/source-fields'
|
|
6
6
|
import { isSourcePath } from '../watch/source-path'
|
|
7
7
|
import { BUN_FAILED_TEST_PATTERN, CLEAN_REVIEW_TEXT } from '../watch/watch-defaults'
|
|
8
|
+
import { MAX_AUTO_WINDOW_DAYS, windowDays } from './auto-window'
|
|
8
9
|
import { BUN_COMMANDS, DEFAULT_BASE_BRANCH } from './detect'
|
|
9
10
|
import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
|
|
10
11
|
import { hasNestedQuantifier } from './nested-quantifier'
|
|
@@ -127,6 +128,20 @@ const parallelTasksSchema = z.strictObject({
|
|
|
127
128
|
* the first answer of a lane's session carried.
|
|
128
129
|
*/
|
|
129
130
|
maxTokens: z.number().int().positive().default(DEFAULT_PARALLEL_TASKS_MAX_TOKENS),
|
|
131
|
+
/**
|
|
132
|
+
* The days, both included, on which a proposal that keeps every limit is marked `auto` and the coordinator starts it
|
|
133
|
+
* without asking: subagents beside running work in a lane, at most two extra agents, no path that always asks.
|
|
134
|
+
* `since` is the day it was switched on, `until` at most seven days later; outside the window it is off. Unset by
|
|
135
|
+
* default, so every proposal asks.
|
|
136
|
+
*/
|
|
137
|
+
auto: z
|
|
138
|
+
.strictObject({ since: z.iso.date(), until: z.iso.date() })
|
|
139
|
+
.refine(({ since, until }) => until >= since, 'until must not be before since')
|
|
140
|
+
.refine(
|
|
141
|
+
({ since, until }) => windowDays(since, until) <= MAX_AUTO_WINDOW_DAYS,
|
|
142
|
+
`until must be at most ${MAX_AUTO_WINDOW_DAYS} days after since`,
|
|
143
|
+
)
|
|
144
|
+
.optional(),
|
|
130
145
|
})
|
|
131
146
|
|
|
132
147
|
const checksSchema = z.strictObject({
|
|
@@ -269,7 +284,7 @@ const checkFreePattern = z
|
|
|
269
284
|
)
|
|
270
285
|
|
|
271
286
|
/**
|
|
272
|
-
* A path pattern that always asks
|
|
287
|
+
* A path pattern that always asks (`guard.alwaysAsk`): plain path words with wildcards, relative, no
|
|
273
288
|
* empty, `.` or `..` segment (GitHub lists no file with one, so such a pattern would never ask). Broad on purpose (`.github/**`, `**`), since a pattern here only ever adds a question; braces,
|
|
274
289
|
* classes and negation stay out, so the hook, which has no schema library, reads exactly what this accepts.
|
|
275
290
|
*/
|
|
@@ -304,13 +319,13 @@ const mergeSchema = z.strictObject({
|
|
|
304
319
|
withoutChecks: z.array(checkFreePattern).default([]),
|
|
305
320
|
})
|
|
306
321
|
|
|
307
|
-
const
|
|
322
|
+
const guardFields = z.strictObject({
|
|
308
323
|
/**
|
|
309
|
-
* The commands the merge guard hook asks the user about before they run,
|
|
310
|
-
* a wrapper or `sh -c`. `gh api
|
|
311
|
-
* guard off.
|
|
324
|
+
* The commands the merge guard hook asks the user about before they run, where they stand at a program position, also
|
|
325
|
+
* behind options before the verb, a chain, a wrapper or `sh -c`. `gh api`, when listed, asks only for a write to a
|
|
326
|
+
* merge, a file, a dispatch, a rerun or a ref. An empty list switches the guard off.
|
|
312
327
|
*/
|
|
313
|
-
commands: z.array(guardedCommand).default(['gh pr merge', 'gh workflow run'
|
|
328
|
+
commands: z.array(guardedCommand).default(['gh pr merge', 'gh workflow run']),
|
|
314
329
|
selfMerge: z
|
|
315
330
|
.strictObject({
|
|
316
331
|
/** Whether `/orchestrate` offers "merge it yourself after green checks" in the pull request question; off also stops the hook honouring such an answer. */
|
|
@@ -325,32 +340,49 @@ const guardSchema = z.strictObject({
|
|
|
325
340
|
waitMinutes: z.number().int().min(1).max(9).default(8),
|
|
326
341
|
})
|
|
327
342
|
.prefault({}),
|
|
343
|
+
/**
|
|
344
|
+
* Paths that always ask, on top of the ones jackherd brings (`.github/**`, `**\/migrations/**`, `**\/.env*`,
|
|
345
|
+
* `.claude/**`, `.jackherd/**`, `bunfig.toml`, `**\/CLAUDE.md`, `**\/AGENTS.md`, `.agents/**`, `.mcp.json`,
|
|
346
|
+
* `.husky/**`, `lefthook.yml`, the lockfiles of bun, npm, pnpm and yarn, `**\/.npmrc`, `**\/CODEOWNERS`): a
|
|
347
|
+
* `merge.withoutChecks` pattern never opens them, and the watcher never starts parallel work on them by itself. They
|
|
348
|
+
* add to that list, never replace it. A standing approval covers them; only workflows, migrations and env files ask.
|
|
349
|
+
*/
|
|
350
|
+
alwaysAsk: z.array(alwaysAskPattern).default([]),
|
|
351
|
+
/**
|
|
352
|
+
* Checks that must be among the head commit's and green before a standing approval lets a merge pass, by their names
|
|
353
|
+
* on GitHub. Unset, the merge needs at least one check and every one green; set, also each of these. Either way
|
|
354
|
+
* GitHub must be able to merge it.
|
|
355
|
+
*/
|
|
356
|
+
requiredChecks: z.array(checkName).optional(),
|
|
357
|
+
/** Read until 2.0.0 as `alwaysAsk` (`exceptPaths`) and `requiredChecks`; the plan approval it was for is gone. */
|
|
328
358
|
planApproval: z
|
|
329
|
-
.strictObject({
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
* `.claude/**`, `.jackherd/**`, `bunfig.toml`, `**\/CLAUDE.md`, `**\/AGENTS.md`, `.agents/**`, `.mcp.json`,
|
|
333
|
-
* `.husky/**`, `lefthook.yml`, the lockfiles of bun, npm, pnpm and yarn, `**\/.npmrc`, `**\/CODEOWNERS`, every
|
|
334
|
-
* package.json, and the CHANGELOG.md of every package without `"private": true`):
|
|
335
|
-
* a pull request that changes one is never merged on a plan approval alone. They add to that list, never replace
|
|
336
|
-
* it; an invalid entry turns plan approval off, so every merge asks.
|
|
337
|
-
*/
|
|
338
|
-
exceptPaths: z.array(alwaysAskPattern).default([]),
|
|
339
|
-
/**
|
|
340
|
-
* Checks that must be among the head commit's and green before a plan approval lets a merge pass, by their names
|
|
341
|
-
* on GitHub. Unset, the merge needs at least one check and every one green; set, also each of these. Either way
|
|
342
|
-
* GitHub must be able to merge it and the head must contain the base branch.
|
|
343
|
-
*/
|
|
344
|
-
requiredChecks: z.array(checkName).optional(),
|
|
345
|
-
})
|
|
346
|
-
.prefault({}),
|
|
359
|
+
.strictObject({ exceptPaths: z.array(alwaysAskPattern).optional(), requiredChecks: z.array(checkName).optional() })
|
|
360
|
+
.optional()
|
|
361
|
+
.meta({ deprecated: true }),
|
|
347
362
|
})
|
|
348
363
|
|
|
364
|
+
/** A config from before 1.10.0 kept the paths that always ask and the required checks under `planApproval`. */
|
|
365
|
+
const guardWithoutPlanApproval = ({ planApproval, ...guard }: z.output<typeof guardFields>) => {
|
|
366
|
+
const requiredChecks = guard.requiredChecks ?? planApproval?.requiredChecks
|
|
367
|
+
return {
|
|
368
|
+
...guard,
|
|
369
|
+
alwaysAsk: [...guard.alwaysAsk, ...(planApproval?.exceptPaths ?? [])],
|
|
370
|
+
...(requiredChecks === undefined ? {} : { requiredChecks }),
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const guardSchema = guardFields.transform(guardWithoutPlanApproval)
|
|
375
|
+
|
|
349
376
|
const hintsSchema = z.strictObject({
|
|
350
377
|
/** Whether the `ask-hint` hook points out a last message that ends in a question while the turn used no `AskUserQuestion`; a lane session is never hinted. */
|
|
351
378
|
askUserQuestion: z.boolean().default(true),
|
|
379
|
+
/** Whether the `task-hint` hook points a lane that edits a file without an open task signal to `jh task start`, once per task. */
|
|
380
|
+
taskSignal: z.boolean().default(true),
|
|
352
381
|
})
|
|
353
382
|
|
|
383
|
+
/** Minutes a lane works without a task signal before the watcher reports `no task signal`. */
|
|
384
|
+
const DEFAULT_TASK_SIGNAL_MINUTES = 10
|
|
385
|
+
|
|
354
386
|
const contextSchema = z
|
|
355
387
|
.strictObject({
|
|
356
388
|
/** Tokens in a lane's context from which its fill shows yellow and `/implement` offers a new chat. */
|
|
@@ -449,6 +481,7 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
449
481
|
* Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
|
|
450
482
|
* it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
|
|
451
483
|
* `parallelTasks.maxTokens` caps the cost of the extra agents in a proposal of parallel tasks at the heartbeat.
|
|
484
|
+
* `taskSignalMinutes` is how long a lane works without a task signal before it reports `no task signal`, once per phase.
|
|
452
485
|
* `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
|
|
453
486
|
* Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
|
|
454
487
|
* command whose JSON output reports `source` for a new or changed entry.
|
|
@@ -459,6 +492,7 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
459
492
|
feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
|
|
460
493
|
checks: checksSchema.prefault({}),
|
|
461
494
|
parallelTasks: parallelTasksSchema.prefault({}),
|
|
495
|
+
taskSignalMinutes: z.number().int().positive().default(DEFAULT_TASK_SIGNAL_MINUTES),
|
|
462
496
|
previews: previewsSchema.optional(),
|
|
463
497
|
sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
|
|
464
498
|
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { twoDigits } from '../plan-format/clock'
|
|
2
|
+
import { laneNameOf, parseSprintLanes } from '../plan-format/lanes'
|
|
3
|
+
import { firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
|
|
4
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
|
+
import { PLAN_LANGUAGE, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
6
|
+
import { BOARD_LANE, type BoardLane } from './close-check'
|
|
7
|
+
|
|
8
|
+
const CONTINUATION = '↳'
|
|
9
|
+
const FIRST_CELL = /^\|((?:[^|\\]|\\.)*)\|/
|
|
10
|
+
|
|
11
|
+
const HISTORY_HEADER: Record<PlanLanguage, readonly [string, string]> = {
|
|
12
|
+
[PLAN_LANGUAGE.de]: ['Datum', 'Ereignis'],
|
|
13
|
+
[PLAN_LANGUAGE.en]: ['Date', 'Event'],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const isoDate = (time: Date): string =>
|
|
17
|
+
`${time.getFullYear()}-${twoDigits(time.getMonth() + 1)}-${twoDigits(time.getDate())}`
|
|
18
|
+
|
|
19
|
+
/** The word for a cleaned-up lane and the day, as a name cell carries it: `abgeräumt DD.MM.`, `cleaned up YYYY-MM-DD`. */
|
|
20
|
+
export const closedMark = (time: Date, language: PlanLanguage): string => {
|
|
21
|
+
const day =
|
|
22
|
+
language === PLAN_LANGUAGE.de ? `${twoDigits(time.getDate())}.${twoDigits(time.getMonth() + 1)}.` : isoDate(time)
|
|
23
|
+
return `${PLAN_VOCABULARY[language].laneClosed} ${day}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The date cell of a history row, `YYYY-MM-DD` in every plan language. */
|
|
27
|
+
export const historyDate = (time: Date): string => isoDate(time)
|
|
28
|
+
|
|
29
|
+
/** How the lanes table names `lane`, read as the herd reads it. */
|
|
30
|
+
export const boardLaneOf = (board: string, lane: string, language: PlanLanguage): BoardLane => {
|
|
31
|
+
const found = parseSprintLanes(board, language).find(({ name }) => name === lane)
|
|
32
|
+
if (found === undefined) return BOARD_LANE.missing
|
|
33
|
+
return found.closed ? BOARD_LANE.closed : BOARD_LANE.open
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The line indexes of the lane's current rows: the rows since it was last cleaned up, as `parseSprintLanes` keeps them,
|
|
38
|
+
* a row that names the lane again without the mark starting it anew. Empty when the lane is closed or missing.
|
|
39
|
+
*/
|
|
40
|
+
const currentRowIndexes = (board: string, lane: string, language: PlanLanguage): number[] => {
|
|
41
|
+
const { sections, laneClosed } = PLAN_VOCABULARY[language]
|
|
42
|
+
const start = sectionStart(board, sections.lanes)
|
|
43
|
+
if (start === -1) return []
|
|
44
|
+
let previous: string | null = null
|
|
45
|
+
let closed = false
|
|
46
|
+
let indexes: number[] = []
|
|
47
|
+
for (const { index, cells } of firstTableRows(sectionLines(board, sections.lanes))) {
|
|
48
|
+
const first = cells[0] ?? ''
|
|
49
|
+
const name = laneNameOf(first, previous)
|
|
50
|
+
previous = name ?? previous
|
|
51
|
+
if (name !== lane) continue
|
|
52
|
+
const marked = first.includes(laneClosed)
|
|
53
|
+
if (closed && !marked && !first.startsWith(CONTINUATION)) {
|
|
54
|
+
closed = false
|
|
55
|
+
indexes = []
|
|
56
|
+
}
|
|
57
|
+
closed ||= marked
|
|
58
|
+
indexes.push(start + index)
|
|
59
|
+
}
|
|
60
|
+
return closed ? [] : indexes
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const withMarkedNameCell = (line: string, mark: string): string =>
|
|
64
|
+
line.replace(FIRST_CELL, (_cell, name: string) => `| ${name.trim()}, ${mark} |`)
|
|
65
|
+
|
|
66
|
+
/** The board with `, <mark>` appended to the name cell of each current row of the lane; unchanged without one. */
|
|
67
|
+
export const withLaneClosed = (board: string, lane: string, mark: string, language: PlanLanguage): string => {
|
|
68
|
+
const marked = new Set(currentRowIndexes(board, lane, language))
|
|
69
|
+
return board
|
|
70
|
+
.split('\n')
|
|
71
|
+
.map((line, index) => (marked.has(index) ? withMarkedNameCell(line, mark) : line))
|
|
72
|
+
.join('\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Text that stays inside one table cell: on one line, with its pipes escaped. */
|
|
76
|
+
const cell = (text: string): string => terminalSafe(text.replace(/\s+/g, ' ')).trim().replaceAll('|', '\\|')
|
|
77
|
+
|
|
78
|
+
const historyTableHead = (language: PlanLanguage): string[] => {
|
|
79
|
+
const header = HISTORY_HEADER[language]
|
|
80
|
+
return [`| ${header.join(' | ')} |`, `|${header.map((name) => '-'.repeat(name.length + 2)).join('|')}|`]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const withoutTrailingBlanks = (lines: readonly string[]): string[] => {
|
|
84
|
+
let end = lines.length
|
|
85
|
+
while (end > 0 && lines[end - 1].trim() === '') end -= 1
|
|
86
|
+
return lines.slice(0, end)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The board with a row on top of its history table, newest first as the coordinator writes them. A history section
|
|
91
|
+
* without a table gets one right under its heading; a board without the section gets it at its end.
|
|
92
|
+
*/
|
|
93
|
+
export const withHistoryRow = (board: string, date: string, text: string, language: PlanLanguage): string => {
|
|
94
|
+
const heading = PLAN_VOCABULARY[language].sections.history
|
|
95
|
+
const row = `| ${date} | ${cell(text)} |`
|
|
96
|
+
const lines = board.split('\n')
|
|
97
|
+
const start = sectionStart(board, heading)
|
|
98
|
+
if (start === -1)
|
|
99
|
+
return [...withoutTrailingBlanks(lines), '', `## ${heading}`, '', ...historyTableHead(language), row, ''].join('\n')
|
|
100
|
+
const tableAt = sectionLines(board, heading).findIndex((line) => line.startsWith('|'))
|
|
101
|
+
const firstRow = start + tableAt + 2
|
|
102
|
+
if (tableAt !== -1) return [...lines.slice(0, firstRow), row, ...lines.slice(firstRow)].join('\n')
|
|
103
|
+
const at = lines[start]?.trim() === '' ? start + 1 : start
|
|
104
|
+
const table = [...historyTableHead(language), row, ...(lines[start]?.trim() === '' ? [''] : [])]
|
|
105
|
+
return [...lines.slice(0, at), ...table, ...lines.slice(at)].join('\n')
|
|
106
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/** The lane's merged pull request: the newest one on its branch, with the commit the merge left on the base. */
|
|
2
|
+
export type MergedPullRequest = { number: number; headCommit: string; mergeCommit: string }
|
|
3
|
+
|
|
4
|
+
import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
|
|
5
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
6
|
+
|
|
7
|
+
/** How the board's lanes table names the lane: with open rows, only with rows marked cleaned up, or not at all. */
|
|
8
|
+
export const BOARD_LANE = { open: 'open', closed: 'closed', missing: 'missing' } as const
|
|
9
|
+
|
|
10
|
+
export type BoardLane = (typeof BOARD_LANE)[keyof typeof BOARD_LANE]
|
|
11
|
+
|
|
12
|
+
/** The lane's branch, the head the check compared, and its merged pull request once nothing stands against closing it. */
|
|
13
|
+
export type ClosableLane = { branch: string; head: string; pullRequest: MergedPullRequest }
|
|
14
|
+
|
|
15
|
+
/** Every reason against closing, or the lane ready to close; the second only when the first is empty. */
|
|
16
|
+
export type CloseVerdict = { kind: 'refused'; refusals: string[] } | ({ kind: 'closable' } & ClosableLane)
|
|
17
|
+
|
|
18
|
+
/** One entry of `git status --porcelain`: its two status letters (`!!` ignored, `??` untracked) and its path. */
|
|
19
|
+
export type StatusEntry = { code: string; path: string }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What git, GitHub and the terminal answered about one lane, gathered before anything is removed. `treeDiff` lists the
|
|
23
|
+
* paths in which the branch head differs from the merge commit; `status` holds the worktree's status with ignored
|
|
24
|
+
* files (`statusEntriesOf`); `setupFiles` the patterns of the lane setup's copy list; `handoverEntries` every
|
|
25
|
+
* entry of the worktree's handover directory, which git shows only as the directory.
|
|
26
|
+
*/
|
|
27
|
+
export type CloseFacts = {
|
|
28
|
+
lane: string
|
|
29
|
+
boardLane: BoardLane
|
|
30
|
+
worktreeExists: boolean
|
|
31
|
+
branch: string | null
|
|
32
|
+
/** The commit the worktree's branch points to, as `git worktree list` reports it; null without a worktree. */
|
|
33
|
+
head: string | null
|
|
34
|
+
pullRequest: MergedPullRequest | null
|
|
35
|
+
treeDiff: readonly string[]
|
|
36
|
+
status: readonly StatusEntry[]
|
|
37
|
+
setupFiles: readonly string[]
|
|
38
|
+
handoverEntries: readonly string[]
|
|
39
|
+
/** The agent named after the lane whose directory lies in the worktree, with its pane. */
|
|
40
|
+
agent: { pane: string; status: string } | null
|
|
41
|
+
/** The panes of other agents whose directory lies in the worktree: a helper, or a session without the lane's name. */
|
|
42
|
+
otherAgents: readonly string[]
|
|
43
|
+
/** Whether the command itself runs in the worktree it would remove. */
|
|
44
|
+
runsInside: boolean
|
|
45
|
+
/** The reason `git worktree lock` recorded, empty for a lock without one; null when the worktree is not locked. */
|
|
46
|
+
locked: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const RESTING_AGENT = new Set<string>([AGENT_STATUS.idle, AGENT_STATUS.done])
|
|
50
|
+
const IGNORED_CODE = '!!'
|
|
51
|
+
const RENAME_OR_COPY = new Set(['R', 'C'])
|
|
52
|
+
/** A status field is two letters, a space, then the path. */
|
|
53
|
+
const STATUS_PATH_START = 3
|
|
54
|
+
const DEPENDENCY_DIRECTORY = 'node_modules'
|
|
55
|
+
const BUILD_OUTPUT_DIRECTORIES = ['dist', '.turbo', 'test-results', '__pycache__']
|
|
56
|
+
const BUILD_OUTPUT = [
|
|
57
|
+
...BUILD_OUTPUT_DIRECTORIES.flatMap((directory) => [`**/${directory}`, `**/${directory}/**`]),
|
|
58
|
+
'**/*.tsbuildinfo',
|
|
59
|
+
'packages/*/bin/*.{js,mjs,cjs}',
|
|
60
|
+
'.husky/_',
|
|
61
|
+
'.husky/_/**',
|
|
62
|
+
].map((glob) => new Bun.Glob(glob))
|
|
63
|
+
/** Build output only as a directory entry: a package's `bin/` that an ignore rule hides as a whole. */
|
|
64
|
+
const BUILD_OUTPUT_DIRECTORY_ENTRIES = [new Bun.Glob('packages/*/bin')]
|
|
65
|
+
const HANDOVER = claudePath(HANDOVER_DIRECTORY)
|
|
66
|
+
|
|
67
|
+
const withoutSlashes = (path: string): string => path.replace(/^\/+|\/+$/g, '')
|
|
68
|
+
|
|
69
|
+
const isHandoverDirectory = (path: string): boolean => withoutSlashes(path) === HANDOVER
|
|
70
|
+
|
|
71
|
+
/** The patterns of a `.gitignore`-style list that name files; comments, blanks and negations name none. */
|
|
72
|
+
const patternsOf = (lines: readonly string[]): string[] =>
|
|
73
|
+
lines.map((line) => line.trim()).filter((line) => line !== '' && !line.startsWith('#') && !line.startsWith('!'))
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether `path` (relative to the worktree, a directory with its trailing slash) is one the lane setup copies in: a
|
|
77
|
+
* pattern with an inner slash is anchored at the root, one without matches the name at any depth, as in `.gitignore`.
|
|
78
|
+
*/
|
|
79
|
+
export const isSetupFile = (path: string, setupFiles: readonly string[]): boolean => {
|
|
80
|
+
const target = withoutSlashes(path)
|
|
81
|
+
return patternsOf(setupFiles).some((pattern) => {
|
|
82
|
+
const bare = withoutSlashes(pattern)
|
|
83
|
+
const glob = bare.includes('/') ? bare : `**/${bare}`
|
|
84
|
+
return new Bun.Glob(glob).match(target) || new Bun.Glob(bare).match(target)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isDependencyPath = (path: string): boolean => withoutSlashes(path).split('/').includes(DEPENDENCY_DIRECTORY)
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The entries of `git status --porcelain=v1 -z --ignored=matching`: NUL-separated, never quoted, a rename or copy followed by
|
|
92
|
+
* its source as a field of its own, which is skipped. Names are read as git wrote them, so no file name can pose as
|
|
93
|
+
* another path.
|
|
94
|
+
*/
|
|
95
|
+
export const statusEntriesOf = (output: string): StatusEntry[] => {
|
|
96
|
+
const fields = output.split('\0')
|
|
97
|
+
const entries: StatusEntry[] = []
|
|
98
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
99
|
+
const field = fields[i]
|
|
100
|
+
if (field.length < STATUS_PATH_START) continue
|
|
101
|
+
const code = field.slice(0, 2)
|
|
102
|
+
entries.push({ code, path: field.slice(STATUS_PATH_START) })
|
|
103
|
+
if (RENAME_OR_COPY.has(code[0])) i += 1
|
|
104
|
+
}
|
|
105
|
+
return entries
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether `path` (relative to the worktree, a directory with its trailing slash) is output a build makes again, from a
|
|
110
|
+
* fixed list: `dist`, `.turbo`, `test-results` and `__pycache__` at any depth, a `*.tsbuildinfo`, a
|
|
111
|
+
* `packages/*\/bin/*.{js,mjs,cjs}` or a whole `packages/*\/bin/`, and husky's helpers in `.husky/_`. Everything else an
|
|
112
|
+
* ignore rule hides may hold work and is not.
|
|
113
|
+
*/
|
|
114
|
+
export const isBuildOutput = (path: string): boolean => {
|
|
115
|
+
const target = withoutSlashes(path)
|
|
116
|
+
const directory = path.endsWith('/') && BUILD_OUTPUT_DIRECTORY_ENTRIES.some((glob) => glob.match(target))
|
|
117
|
+
return directory || BUILD_OUTPUT.some((glob) => glob.match(target))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The paths removing the worktree would delete for good: every tracked change and untracked file, and every ignored
|
|
122
|
+
* path except dependencies (`node_modules` at any depth), the setup files, build output and the handover directory.
|
|
123
|
+
*/
|
|
124
|
+
export const lostPathsOf = (status: readonly StatusEntry[], setupFiles: readonly string[]): string[] => {
|
|
125
|
+
const kept = (path: string) =>
|
|
126
|
+
isDependencyPath(path) || isSetupFile(path, setupFiles) || isBuildOutput(path) || isHandoverDirectory(path)
|
|
127
|
+
return status.filter(({ code, path }) => code !== IGNORED_CODE || !kept(path)).map(({ path }) => path)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The lost paths and the entries of the handover directory, each once: the directory stands for all of them. */
|
|
131
|
+
const deletedPathsOf = ({ status, setupFiles, handoverEntries }: CloseFacts): string[] => [
|
|
132
|
+
...new Set([...lostPathsOf(status, setupFiles), ...handoverEntries]),
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
/** A lane the board marks cleaned up already may still have its worktree left over; only a lane it never named is refused. */
|
|
136
|
+
const boardRefusals = ({ lane, boardLane }: CloseFacts): string[] =>
|
|
137
|
+
boardLane === BOARD_LANE.missing ? [`the board has no row for lane ${lane}`] : []
|
|
138
|
+
|
|
139
|
+
const mergeRefusals = ({ lane, branch, pullRequest, treeDiff }: CloseFacts): string[] => {
|
|
140
|
+
if (branch === null) return [`the worktree of ${lane} is on no branch`]
|
|
141
|
+
if (pullRequest === null) return [`branch ${branch} has no merged pull request`]
|
|
142
|
+
return treeDiff.map(
|
|
143
|
+
(path) => `branch ${branch} differs from the merge of #${pullRequest.number} (${pullRequest.mergeCommit}): ${path}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const agentRefusals = ({ lane, agent, otherAgents }: CloseFacts): string[] => [
|
|
148
|
+
...(agent === null || RESTING_AGENT.has(agent.status) ? [] : [`the agent of ${lane} is ${agent.status}`]),
|
|
149
|
+
...otherAgents.map((pane) => `pane ${pane} runs another agent in the worktree of ${lane}`),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
const lockRefusals = ({ lane, locked }: CloseFacts): string[] =>
|
|
153
|
+
locked === null
|
|
154
|
+
? []
|
|
155
|
+
: [`the worktree of ${lane} is locked${locked ? ` (${locked})` : ''}; unlock it once its session has ended`]
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every reason not to close the lane, all at once; empty when nothing would be lost. Closing needs an open board row,
|
|
159
|
+
* the worktree on a branch whose newest merged pull request left the same tree on the base (a squash merge leaves the
|
|
160
|
+
* branch unmerged in git's eyes, so the trees are compared), nothing in the worktree beyond dependencies, the setup
|
|
161
|
+
* files and build output (`isBuildOutput`), an empty handover directory, an agent that is gone or rests, and no lock
|
|
162
|
+
* on the worktree (a Claude Code session may still hold it).
|
|
163
|
+
*/
|
|
164
|
+
export const closeRefusals = (facts: CloseFacts): string[] => {
|
|
165
|
+
if (!facts.worktreeExists) return [...boardRefusals(facts), `lane ${facts.lane} has no worktree`]
|
|
166
|
+
if (facts.runsInside) return [`run it outside the worktree of ${facts.lane}, which it would remove`]
|
|
167
|
+
return [
|
|
168
|
+
...boardRefusals(facts),
|
|
169
|
+
...mergeRefusals(facts),
|
|
170
|
+
...deletedPathsOf(facts).map((path) => `removing the worktree deletes ${path}`),
|
|
171
|
+
...agentRefusals(facts),
|
|
172
|
+
...lockRefusals(facts),
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `closeRefusals` as a verdict that carries the branch and pull request it proved, so a caller needs no second check. */
|
|
177
|
+
export const closeVerdict = (facts: CloseFacts): CloseVerdict => {
|
|
178
|
+
const refusals = closeRefusals(facts)
|
|
179
|
+
const { branch, head, pullRequest } = facts
|
|
180
|
+
if (refusals.length > 0 || branch === null || head === null || pullRequest === null)
|
|
181
|
+
return { kind: 'refused', refusals }
|
|
182
|
+
return { kind: 'closable', branch, head, pullRequest }
|
|
183
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
3
|
+
|
|
4
|
+
/** The command as the usage, the board's history and the watcher's status line name it. */
|
|
5
|
+
export const LANE_CLOSE_COMMAND = `${CLI_NAME} lane close`
|
|
6
|
+
|
|
7
|
+
export const DRY_RUN_OPTION = '--dry-run'
|
|
8
|
+
|
|
9
|
+
/** The dry run the watcher suggests for a lane; null for a name no command line should carry. */
|
|
10
|
+
export const closeDryRunOf = (lane: string): string | null =>
|
|
11
|
+
SAFE_LANE_NAME.test(lane) ? `${LANE_CLOSE_COMMAND} ${lane} ${DRY_RUN_OPTION}` : null
|