jackherd 1.9.0 → 1.11.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 +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- 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 +57 -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/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -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 +5 -1
- package/schemas/jackherd.schema.json +139 -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 +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- 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/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -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/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- 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 +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- 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/planned.ts +22 -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/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- 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 +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- 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 +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -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 +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- 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/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +47 -4
- 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,8 @@ 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'),
|
|
28
|
+
mermaid: () => import('./mermaid/command'),
|
|
27
29
|
preview: () => import('./preview/command'),
|
|
28
30
|
settled: () => import('./settled/command'),
|
|
29
31
|
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,10 +5,12 @@ 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'
|
|
11
12
|
import { isRepositoryDirectory } from './repository-directory'
|
|
13
|
+
import { DEFAULT_TONES, TONE_NAMES } from './tones'
|
|
12
14
|
|
|
13
15
|
export const TERMINAL_ADAPTER = { none: 'none', herdr: 'herdr' } as const
|
|
14
16
|
|
|
@@ -127,6 +129,20 @@ const parallelTasksSchema = z.strictObject({
|
|
|
127
129
|
* the first answer of a lane's session carried.
|
|
128
130
|
*/
|
|
129
131
|
maxTokens: z.number().int().positive().default(DEFAULT_PARALLEL_TASKS_MAX_TOKENS),
|
|
132
|
+
/**
|
|
133
|
+
* The days, both included, on which a proposal that keeps every limit is marked `auto` and the coordinator starts it
|
|
134
|
+
* without asking: subagents beside running work in a lane, at most two extra agents, no path that always asks.
|
|
135
|
+
* `since` is the day it was switched on, `until` at most seven days later; outside the window it is off. Unset by
|
|
136
|
+
* default, so every proposal asks.
|
|
137
|
+
*/
|
|
138
|
+
auto: z
|
|
139
|
+
.strictObject({ since: z.iso.date(), until: z.iso.date() })
|
|
140
|
+
.refine(({ since, until }) => until >= since, 'until must not be before since')
|
|
141
|
+
.refine(
|
|
142
|
+
({ since, until }) => windowDays(since, until) <= MAX_AUTO_WINDOW_DAYS,
|
|
143
|
+
`until must be at most ${MAX_AUTO_WINDOW_DAYS} days after since`,
|
|
144
|
+
)
|
|
145
|
+
.optional(),
|
|
130
146
|
})
|
|
131
147
|
|
|
132
148
|
const checksSchema = z.strictObject({
|
|
@@ -161,7 +177,7 @@ const sourcePath = z.string().refine(isSourcePath, 'not a source path: `.`, or `
|
|
|
161
177
|
|
|
162
178
|
const SOURCE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
|
|
163
179
|
|
|
164
|
-
/** The
|
|
180
|
+
/** The eight known fields, and any further field (`priority`, `assignee`, …) whose name is a plain name, for `reportOn`. */
|
|
165
181
|
const sourceMapSchema = z
|
|
166
182
|
.object({
|
|
167
183
|
[SOURCE_FIELD.id]: sourcePath,
|
|
@@ -178,6 +194,8 @@ const sourceMapSchema = z
|
|
|
178
194
|
[SOURCE_FIELD.author]: sourcePath.optional(),
|
|
179
195
|
/** A ticket source's status, shown on its badge in the herd; also a field `reportOn` can name. */
|
|
180
196
|
[SOURCE_FIELD.status]: sourcePath.optional(),
|
|
197
|
+
/** How often the entry happened (an error count), shown as `113×` beside the entry in the herd's `Sources` block. */
|
|
198
|
+
[SOURCE_FIELD.count]: sourcePath.optional(),
|
|
181
199
|
})
|
|
182
200
|
.catchall(sourcePath)
|
|
183
201
|
.refine((map) => Object.keys(map).every((field) => SOURCE_FIELD_NAME.test(field)), {
|
|
@@ -188,6 +206,8 @@ const sourceMapSchema = z
|
|
|
188
206
|
const canChange = ({ map, reportOn }: { map: Record<string, string | undefined>; reportOn?: string[] }): boolean =>
|
|
189
207
|
map.updatedAt !== undefined || CONTENT_FIELDS.some((field) => map[field] !== undefined) || reportOn !== undefined
|
|
190
208
|
|
|
209
|
+
const toneName = z.enum(TONE_NAMES)
|
|
210
|
+
|
|
191
211
|
const sourceSchema = z
|
|
192
212
|
.strictObject({
|
|
193
213
|
/** Printed in every report of the source and the key of its saved state. */
|
|
@@ -203,6 +223,13 @@ const sourceSchema = z
|
|
|
203
223
|
ignoreAuthors: z.array(z.string().min(1)).default([]),
|
|
204
224
|
/** The most new or changed entries one report shows of this source; the rest is one line `+<n> more`. */
|
|
205
225
|
reportLimit: z.number().int().min(1).default(5),
|
|
226
|
+
/** Shown before a row of "Waiting for you" that came from this source (`Error log · Checkout: …`); without it the source's name. */
|
|
227
|
+
label: z
|
|
228
|
+
.string()
|
|
229
|
+
.min(1)
|
|
230
|
+
.max(40)
|
|
231
|
+
.regex(/^[^\r\n]+$/, 'one line')
|
|
232
|
+
.optional(),
|
|
206
233
|
/** The lane the source's reports name. */
|
|
207
234
|
lane: z.string().min(1).optional(),
|
|
208
235
|
/**
|
|
@@ -210,6 +237,8 @@ const sourceSchema = z
|
|
|
210
237
|
* the lane above or the lane whose board row or plan names its id.
|
|
211
238
|
*/
|
|
212
239
|
ticket: z.boolean().optional(),
|
|
240
|
+
/** The tone of the source's mark in the herd's `Sources` block while its newest report is new; without it the tone of the role `new`. */
|
|
241
|
+
tone: toneName.optional(),
|
|
213
242
|
/**
|
|
214
243
|
* The mapped fields whose change reports, with the old and the new value, in place of the content of `title`,
|
|
215
244
|
* `author`, `body` and `url`; without it a change of that content reports.
|
|
@@ -269,7 +298,7 @@ const checkFreePattern = z
|
|
|
269
298
|
)
|
|
270
299
|
|
|
271
300
|
/**
|
|
272
|
-
* A path pattern that always asks
|
|
301
|
+
* A path pattern that always asks (`guard.alwaysAsk`): plain path words with wildcards, relative, no
|
|
273
302
|
* 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
303
|
* classes and negation stay out, so the hook, which has no schema library, reads exactly what this accepts.
|
|
275
304
|
*/
|
|
@@ -304,13 +333,13 @@ const mergeSchema = z.strictObject({
|
|
|
304
333
|
withoutChecks: z.array(checkFreePattern).default([]),
|
|
305
334
|
})
|
|
306
335
|
|
|
307
|
-
const
|
|
336
|
+
const guardFields = z.strictObject({
|
|
308
337
|
/**
|
|
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.
|
|
338
|
+
* The commands the merge guard hook asks the user about before they run, where they stand at a program position, also
|
|
339
|
+
* behind options before the verb, a chain, a wrapper or `sh -c`. `gh api`, when listed, asks only for a write to a
|
|
340
|
+
* merge, a file, a dispatch, a rerun or a ref. An empty list switches the guard off.
|
|
312
341
|
*/
|
|
313
|
-
commands: z.array(guardedCommand).default(['gh pr merge', 'gh workflow run'
|
|
342
|
+
commands: z.array(guardedCommand).default(['gh pr merge', 'gh workflow run']),
|
|
314
343
|
selfMerge: z
|
|
315
344
|
.strictObject({
|
|
316
345
|
/** Whether `/orchestrate` offers "merge it yourself after green checks" in the pull request question; off also stops the hook honouring such an answer. */
|
|
@@ -325,30 +354,54 @@ const guardSchema = z.strictObject({
|
|
|
325
354
|
waitMinutes: z.number().int().min(1).max(9).default(8),
|
|
326
355
|
})
|
|
327
356
|
.prefault({}),
|
|
357
|
+
/**
|
|
358
|
+
* Paths that always ask, on top of the ones jackherd brings (`.github/**`, `**\/migrations/**`, `**\/.env*`,
|
|
359
|
+
* `.claude/**`, `.jackherd/**`, `bunfig.toml`, `**\/CLAUDE.md`, `**\/AGENTS.md`, `.agents/**`, `.mcp.json`,
|
|
360
|
+
* `.husky/**`, `lefthook.yml`, the lockfiles of bun, npm, pnpm and yarn, `**\/.npmrc`, `**\/CODEOWNERS`): a
|
|
361
|
+
* `merge.withoutChecks` pattern never opens them, and the watcher never starts parallel work on them by itself. They
|
|
362
|
+
* add to that list, never replace it. A standing approval covers them; only workflows, migrations and env files ask.
|
|
363
|
+
*/
|
|
364
|
+
alwaysAsk: z.array(alwaysAskPattern).default([]),
|
|
365
|
+
/**
|
|
366
|
+
* Checks that must be among the head commit's and green before a standing approval lets a merge pass, by their names
|
|
367
|
+
* on GitHub. Unset, the merge needs at least one check and every one green; set, also each of these. Either way
|
|
368
|
+
* GitHub must be able to merge it.
|
|
369
|
+
*/
|
|
370
|
+
requiredChecks: z.array(checkName).optional(),
|
|
371
|
+
/** Read until 2.0.0 as `alwaysAsk` (`exceptPaths`) and `requiredChecks`; the plan approval it was for is gone. */
|
|
328
372
|
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({}),
|
|
373
|
+
.strictObject({ exceptPaths: z.array(alwaysAskPattern).optional(), requiredChecks: z.array(checkName).optional() })
|
|
374
|
+
.optional()
|
|
375
|
+
.meta({ deprecated: true }),
|
|
347
376
|
})
|
|
348
377
|
|
|
378
|
+
/** A config from before 1.10.0 kept the paths that always ask and the required checks under `planApproval`. */
|
|
379
|
+
const guardWithoutPlanApproval = ({ planApproval, ...guard }: z.output<typeof guardFields>) => {
|
|
380
|
+
const requiredChecks = guard.requiredChecks ?? planApproval?.requiredChecks
|
|
381
|
+
return {
|
|
382
|
+
...guard,
|
|
383
|
+
alwaysAsk: [...guard.alwaysAsk, ...(planApproval?.exceptPaths ?? [])],
|
|
384
|
+
...(requiredChecks === undefined ? {} : { requiredChecks }),
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const guardSchema = guardFields.transform(guardWithoutPlanApproval)
|
|
389
|
+
|
|
349
390
|
const hintsSchema = z.strictObject({
|
|
350
391
|
/** 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
392
|
askUserQuestion: z.boolean().default(true),
|
|
393
|
+
/** Whether the `task-hint` hook points a lane that edits a file without an open task signal to `jh task start`, once per task. */
|
|
394
|
+
taskSignal: z.boolean().default(true),
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
/** Minutes a lane works without a task signal before the watcher reports `no task signal`. */
|
|
398
|
+
const DEFAULT_TASK_SIGNAL_MINUTES = 10
|
|
399
|
+
|
|
400
|
+
const tonesSchema = z.strictObject({
|
|
401
|
+
waiting: toneName.default(DEFAULT_TONES.waiting),
|
|
402
|
+
new: toneName.default(DEFAULT_TONES.new),
|
|
403
|
+
running: toneName.default(DEFAULT_TONES.running),
|
|
404
|
+
resting: toneName.default(DEFAULT_TONES.resting),
|
|
352
405
|
})
|
|
353
406
|
|
|
354
407
|
const contextSchema = z
|
|
@@ -443,12 +496,15 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
443
496
|
)
|
|
444
497
|
.default(DEFAULT_LINK_KINDS.map((kind) => ({ ...kind }))),
|
|
445
498
|
context: contextSchema.prefault({}),
|
|
499
|
+
/** The colour of each role in the herd, by name: what waits for you, what is new, what runs, what rests. */
|
|
500
|
+
tones: tonesSchema.prefault({}),
|
|
446
501
|
})
|
|
447
502
|
.prefault({}),
|
|
448
503
|
/**
|
|
449
504
|
* Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
|
|
450
505
|
* it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
|
|
451
506
|
* `parallelTasks.maxTokens` caps the cost of the extra agents in a proposal of parallel tasks at the heartbeat.
|
|
507
|
+
* `taskSignalMinutes` is how long a lane works without a task signal before it reports `no task signal`, once per phase.
|
|
452
508
|
* `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
|
|
453
509
|
* Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
|
|
454
510
|
* command whose JSON output reports `source` for a new or changed entry.
|
|
@@ -459,6 +515,7 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
459
515
|
feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
|
|
460
516
|
checks: checksSchema.prefault({}),
|
|
461
517
|
parallelTasks: parallelTasksSchema.prefault({}),
|
|
518
|
+
taskSignalMinutes: z.number().int().positive().default(DEFAULT_TASK_SIGNAL_MINUTES),
|
|
462
519
|
previews: previewsSchema.optional(),
|
|
463
520
|
sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
|
|
464
521
|
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The colours the herd paints with by name; the terminal's own palette decides how each looks, `grey` is the dimmed text colour. */
|
|
2
|
+
export const TONE = {
|
|
3
|
+
amber: 'amber',
|
|
4
|
+
teal: 'teal',
|
|
5
|
+
grey: 'grey',
|
|
6
|
+
red: 'red',
|
|
7
|
+
green: 'green',
|
|
8
|
+
blue: 'blue',
|
|
9
|
+
magenta: 'magenta',
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
export type ToneName = (typeof TONE)[keyof typeof TONE]
|
|
13
|
+
|
|
14
|
+
export const TONE_NAMES = Object.values(TONE)
|
|
15
|
+
|
|
16
|
+
/** What a tone is for in the herd: what waits for you, what is new, what runs, what rests. */
|
|
17
|
+
export const TONE_ROLE = { waiting: 'waiting', new: 'new', running: 'running', resting: 'resting' } as const
|
|
18
|
+
|
|
19
|
+
export type ToneRole = (typeof TONE_ROLE)[keyof typeof TONE_ROLE]
|
|
20
|
+
|
|
21
|
+
/** The tone of each role, as the herd looked before the tones were configurable. */
|
|
22
|
+
export const DEFAULT_TONES: Record<ToneRole, ToneName> = {
|
|
23
|
+
[TONE_ROLE.waiting]: TONE.amber,
|
|
24
|
+
[TONE_ROLE.new]: TONE.amber,
|
|
25
|
+
[TONE_ROLE.running]: TONE.teal,
|
|
26
|
+
[TONE_ROLE.resting]: TONE.grey,
|
|
27
|
+
}
|
|
@@ -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
|
+
}
|