jackherd 1.7.1 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/INSTALL.md +2 -0
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +86 -1037
- package/assets/branding/README.md +5 -7
- package/assets/branding/jackherd-logo-outline.svg +1 -0
- package/assets/branding/outline.test.ts +30 -0
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-OU2KALXP.js} +26 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +75 -7
- package/dist/plan-format.js +180 -100
- package/docs/workflow-dark.svg +1 -1
- package/docs/workflow.svg +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
- package/kit/hooks/hooks/approval-gate/index.ts +10 -8
- package/kit/hooks/hooks/approval-gate/run.ts +21 -12
- package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
- package/kit/hooks/hooks/git-guard/decision.ts +189 -15
- package/kit/hooks/hooks/git-guard/index.ts +13 -3
- package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
- package/kit/hooks/hooks/git-guard/repository.ts +63 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
- package/kit/hooks/hooks/merge-guard/decision.ts +344 -102
- package/kit/hooks/hooks/merge-guard/index.ts +6 -4
- package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
- package/kit/hooks/hooks/merge-guard/run.ts +118 -29
- package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
- package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
- package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
- package/kit/hooks/hooks/shared/base-branch.ts +23 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
- package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
- package/kit/hooks/hooks/shared/command.ts +14 -2
- package/kit/hooks/hooks/shared/commit.ts +2 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
- package/kit/hooks/hooks/shared/json.ts +10 -0
- package/kit/hooks/hooks/shared/lane.ts +57 -0
- package/kit/hooks/hooks/shared/once.ts +8 -0
- package/kit/hooks/hooks/shared/own-pane.ts +31 -0
- package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
- package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
- package/kit/hooks/hooks/shared/program-position.ts +148 -5
- package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
- package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
- package/kit/hooks/hooks/shared/shell.ts +167 -20
- package/kit/hooks/hooks.json +11 -2
- package/kit/hooks/statusline.sh +29 -16
- package/kit/review/skills/diagnose/SKILL.md +1 -1
- package/kit/review/skills/review/SKILL.md +2 -0
- package/kit/rules/rules/kit/testing.md +2 -5
- package/kit/rules/rules/kit/writing-for-agents.md +3 -1
- package/kit/workflow/agents/spec-review.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/grill/SKILL.md +1 -1
- package/kit/workflow/skills/implement/SKILL.md +10 -11
- package/kit/workflow/skills/orchestrate/SKILL.md +156 -117
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
- package/kit/workflow/skills/plan/SKILL.md +5 -2
- package/kit/workflow/skills/plan/references/template-English.md +6 -0
- package/kit/workflow/skills/plan/references/template-German.md +6 -0
- package/package.json +4 -2
- package/recipes/README.md +21 -0
- package/recipes/github-issues.md +23 -0
- package/recipes/github-projects.md +22 -0
- package/recipes/jira-cli.md +26 -0
- package/recipes/jira-rest.md +24 -0
- package/recipes/linear.md +25 -0
- package/recipes/sentry.md +18 -0
- package/recipes/sources/jira.ts +51 -0
- package/recipes/sources/linear.ts +19 -0
- package/recipes/sources/sentry.ts +12 -0
- package/recipes/sources.md +80 -0
- package/recipes/status-page.md +19 -0
- package/schemas/jackherd.schema.json +42 -18
- package/src/check/command.ts +2 -2
- package/src/check/parallel-report.ts +19 -9
- package/src/cli.ts +1 -0
- package/src/config/context-shares.ts +130 -0
- package/src/config/read-config.ts +16 -4
- package/src/config/schema.ts +75 -20
- package/src/context/auto-compact.ts +68 -0
- package/src/context/command.ts +68 -17
- package/src/context/coordinator-name.ts +27 -9
- package/src/context/index.ts +0 -1
- package/src/context/level.ts +18 -11
- package/src/context/report.ts +6 -3
- package/src/plan-format/dependencies.ts +144 -32
- package/src/plan-format/index.ts +6 -0
- package/src/plan-format/lanes.ts +6 -0
- package/src/plan-format/progress.ts +19 -8
- package/src/plans/command.ts +14 -5
- package/src/plans/current-phase.ts +63 -7
- package/src/plans/current-task.ts +51 -0
- package/src/plans/ensure-panes.ts +25 -8
- package/src/plans/frame.ts +99 -0
- package/src/plans/git.ts +8 -0
- package/src/plans/herd-memory.ts +107 -0
- package/src/plans/lane-plans.ts +5 -3
- package/src/plans/lane-standing.ts +5 -4
- package/src/plans/lane-summary.ts +222 -0
- package/src/plans/layout-phase.ts +148 -0
- package/src/plans/layout-summary.ts +140 -0
- package/src/plans/layout.ts +164 -420
- package/src/plans/live.ts +11 -6
- package/src/plans/plan-panes.ts +15 -0
- package/src/plans/planned.ts +9 -42
- package/src/plans/selection.ts +2 -6
- package/src/plans/settings.ts +2 -7
- package/src/plans/snapshot.ts +38 -39
- package/src/plans/symbols.ts +2 -2
- package/src/plans/texts.ts +32 -31
- package/src/plans/view.ts +0 -1
- package/src/plans/worked-phases.ts +2 -2
- package/src/settled/board-row.ts +133 -0
- package/src/settled/check.ts +129 -0
- package/src/settled/command.ts +65 -0
- package/src/settled/evidence-version.ts +14 -0
- package/src/settled/options.ts +69 -0
- package/src/settled/send.ts +27 -0
- package/src/settled/settle.ts +87 -0
- package/src/settled/topics.ts +56 -0
- package/src/sync/render.ts +13 -4
- package/src/sync/sync.ts +25 -5
- package/src/terminal/herdr.ts +46 -10
- package/src/terminal/none.ts +1 -0
- package/src/terminal/types.ts +15 -2
- package/src/update/command.ts +2 -0
- package/src/update/config-shares.ts +30 -0
- package/src/update/update.ts +12 -1
- package/src/vocabulary/index.ts +51 -11
- package/src/watch/appointments.ts +206 -0
- package/src/watch/command.ts +62 -19
- package/src/watch/lane-report.ts +11 -3
- package/src/watch/texts.ts +35 -59
- package/src/watch/watch-events.ts +21 -28
- package/assets/icons/bark-dark.svg +0 -1
- package/assets/icons/bowl-dark.svg +0 -1
- package/assets/icons/decide-dark.svg +0 -1
- package/assets/icons/ear-dark.svg +0 -1
- package/assets/icons/lanes-dark.svg +0 -1
- package/assets/icons/plan-dark.svg +0 -1
- package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { programName, ruleStart } from '../shared/program-position'
|
|
2
|
+
import { expandsBraces, isExpanded, type SimpleCommand, type Word } from '../shared/shell'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What an `xargs` call is told to run: the program word (absent when it runs `echo`) and where it stands among the
|
|
6
|
+
* words after `xargs`, and the strings `-I`, `-i`, `--replace` or BSD's `-J` replace with the input, which may stand
|
|
7
|
+
* anywhere, the program included.
|
|
8
|
+
*/
|
|
9
|
+
type XargsCall = { program: Word | undefined; at: number; replaceStrings: string[] }
|
|
10
|
+
|
|
11
|
+
/** One option word read: the words it used up, and the replace string it set, if any. */
|
|
12
|
+
type OptionRead = { width: number; replaceString?: string }
|
|
13
|
+
|
|
14
|
+
const XARGS = 'xargs'
|
|
15
|
+
const END_OF_OPTIONS = '--'
|
|
16
|
+
const DEFAULT_REPLACE_STRING = '{}'
|
|
17
|
+
const GLOB = /[*?[]/
|
|
18
|
+
const ECHO: Word = { value: 'echo', literal: true }
|
|
19
|
+
|
|
20
|
+
/** GNU and BSD short options: with a value (attached or the next word), with a value only when attached, without one. */
|
|
21
|
+
const SHORT_WITH_VALUE = new Set(['a', 'd', 'E', 'I', 'J', 'L', 'n', 'P', 'R', 's', 'S'])
|
|
22
|
+
const SHORT_WITH_ATTACHED_VALUE = new Set(['e', 'i', 'l'])
|
|
23
|
+
const SHORT_FLAGS = new Set(['0', 'o', 'p', 'r', 't', 'x'])
|
|
24
|
+
const SHORT_REPLACE = new Set(['I', 'J'])
|
|
25
|
+
const SHORT_OLD_REPLACE = 'i'
|
|
26
|
+
|
|
27
|
+
const LONG_WITH_VALUE = new Set([
|
|
28
|
+
'--arg-file',
|
|
29
|
+
'--delimiter',
|
|
30
|
+
'--max-args',
|
|
31
|
+
'--max-chars',
|
|
32
|
+
'--max-procs',
|
|
33
|
+
'--process-slot-var',
|
|
34
|
+
])
|
|
35
|
+
const LONG_WITH_ATTACHED_VALUE = new Set(['--eof', '--max-lines', '--replace'])
|
|
36
|
+
const LONG_FLAGS = new Set([
|
|
37
|
+
'--exit',
|
|
38
|
+
'--help',
|
|
39
|
+
'--interactive',
|
|
40
|
+
'--no-run-if-empty',
|
|
41
|
+
'--null',
|
|
42
|
+
'--open-tty',
|
|
43
|
+
'--show-limits',
|
|
44
|
+
'--verbose',
|
|
45
|
+
'--version',
|
|
46
|
+
])
|
|
47
|
+
const LONG_REPLACE = '--replace'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Programs that read or write the files their arguments name and start nothing, whatever the arguments say: input
|
|
51
|
+
* handed to them by `xargs` stays arguments. A program that runs text or a program its arguments name stays out (`git`
|
|
52
|
+
* with `-c alias.…`, `sed` with `e`, `awk` with `system()`, `rg --pre`, `sort --compress-program`), as does every
|
|
53
|
+
* program not listed: the list is closed, so an unknown program keeps the search of the whole text.
|
|
54
|
+
*/
|
|
55
|
+
const PLAIN_PROGRAMS = new Set([
|
|
56
|
+
'basename',
|
|
57
|
+
'cat',
|
|
58
|
+
'chmod',
|
|
59
|
+
'dirname',
|
|
60
|
+
'du',
|
|
61
|
+
'echo',
|
|
62
|
+
'egrep',
|
|
63
|
+
'fgrep',
|
|
64
|
+
'file',
|
|
65
|
+
'grep',
|
|
66
|
+
'head',
|
|
67
|
+
'jq',
|
|
68
|
+
'ls',
|
|
69
|
+
'md5sum',
|
|
70
|
+
'mkdir',
|
|
71
|
+
'printf',
|
|
72
|
+
'readlink',
|
|
73
|
+
'realpath',
|
|
74
|
+
'rm',
|
|
75
|
+
'rmdir',
|
|
76
|
+
'sha1sum',
|
|
77
|
+
'sha256sum',
|
|
78
|
+
'shasum',
|
|
79
|
+
'stat',
|
|
80
|
+
'tail',
|
|
81
|
+
'touch',
|
|
82
|
+
'tr',
|
|
83
|
+
'uniq',
|
|
84
|
+
'wc',
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A word the shell hands over as written, quotes and escapes aside: an expansion, a glob or braces could become
|
|
89
|
+
* several words, or none, and shift which word is the program.
|
|
90
|
+
*/
|
|
91
|
+
const isFixed = ({ value }: Word): boolean => !isExpanded(value) && !GLOB.test(value) && !expandsBraces(value)
|
|
92
|
+
|
|
93
|
+
/** A value in the next word must be there and fixed. */
|
|
94
|
+
const nextValue = (words: Word[], index: number): Word | undefined => {
|
|
95
|
+
const next = words[index + 1]
|
|
96
|
+
return next !== undefined && isFixed(next) ? next : undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const readLongOption = (words: Word[], index: number): OptionRead | undefined => {
|
|
100
|
+
const { value } = words[index]
|
|
101
|
+
const at = value.indexOf('=')
|
|
102
|
+
const name = at === -1 ? value : value.slice(0, at)
|
|
103
|
+
const attached = at === -1 ? undefined : value.slice(at + 1)
|
|
104
|
+
if (LONG_WITH_VALUE.has(name)) {
|
|
105
|
+
if (attached !== undefined) return { width: 1 }
|
|
106
|
+
return nextValue(words, index) ? { width: 2 } : undefined
|
|
107
|
+
}
|
|
108
|
+
if (LONG_WITH_ATTACHED_VALUE.has(name)) {
|
|
109
|
+
const replaceString = name === LONG_REPLACE ? (attached ?? DEFAULT_REPLACE_STRING) : undefined
|
|
110
|
+
return { width: 1, replaceString }
|
|
111
|
+
}
|
|
112
|
+
return LONG_FLAGS.has(name) && attached === undefined ? { width: 1 } : undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A short option that takes a value: the rest of its word, or the next word when nothing is attached. */
|
|
116
|
+
const readShortValue = (words: Word[], index: number, letter: string, attached: string): OptionRead | undefined => {
|
|
117
|
+
if (attached !== '') return { width: 1, replaceString: SHORT_REPLACE.has(letter) ? attached : undefined }
|
|
118
|
+
const separate = nextValue(words, index)
|
|
119
|
+
if (separate === undefined) return undefined
|
|
120
|
+
return { width: 2, replaceString: SHORT_REPLACE.has(letter) ? separate.value : undefined }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A cluster of short options (`-0rn1`, `-tI{}`): flags, then at most one option that takes the rest as its value. */
|
|
124
|
+
const readShortOptions = (words: Word[], index: number): OptionRead | undefined => {
|
|
125
|
+
const { value } = words[index]
|
|
126
|
+
for (let at = 1; at < value.length; at++) {
|
|
127
|
+
const letter = value[at]
|
|
128
|
+
if (SHORT_FLAGS.has(letter)) continue
|
|
129
|
+
const attached = value.slice(at + 1)
|
|
130
|
+
if (SHORT_WITH_VALUE.has(letter)) return readShortValue(words, index, letter, attached)
|
|
131
|
+
if (!SHORT_WITH_ATTACHED_VALUE.has(letter)) return undefined
|
|
132
|
+
const replaceString = letter === SHORT_OLD_REPLACE ? attached || DEFAULT_REPLACE_STRING : undefined
|
|
133
|
+
return { width: 1, replaceString }
|
|
134
|
+
}
|
|
135
|
+
return { width: 1 }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const readOption = (words: Word[], index: number): OptionRead | undefined =>
|
|
139
|
+
words[index].value.startsWith('--') ? readLongOption(words, index) : readShortOptions(words, index)
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Reads the words after `xargs` up to its program. An option this guard does not know, or a word before the program
|
|
143
|
+
* that is not fixed, makes the call unreadable: undefined.
|
|
144
|
+
*/
|
|
145
|
+
const readXargsCall = (args: Word[]): XargsCall | undefined => {
|
|
146
|
+
const replaceStrings: string[] = []
|
|
147
|
+
for (let index = 0; index < args.length; ) {
|
|
148
|
+
const word = args[index]
|
|
149
|
+
if (!isFixed(word)) return undefined
|
|
150
|
+
if (word.value === END_OF_OPTIONS) return { program: args[index + 1], at: index + 1, replaceStrings }
|
|
151
|
+
if (!word.value.startsWith('-') || word.value === '-') return { program: word, at: index, replaceStrings }
|
|
152
|
+
const option = readOption(args, index)
|
|
153
|
+
if (option === undefined) return undefined
|
|
154
|
+
if (option.replaceString !== undefined) replaceStrings.push(option.replaceString)
|
|
155
|
+
index += option.width
|
|
156
|
+
}
|
|
157
|
+
return { program: undefined, at: args.length, replaceStrings }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const isPlainProgram = (program: Word, replaceStrings: string[], guardedPrograms: readonly string[]): boolean =>
|
|
161
|
+
isFixed(program) &&
|
|
162
|
+
!replaceStrings.some((replaceString) => program.value.includes(replaceString)) &&
|
|
163
|
+
PLAIN_PROGRAMS.has(program.value) &&
|
|
164
|
+
!guardedPrograms.includes(program.value)
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* An `xargs` at the start of the command that runs a fixed program starting nothing (D77) becomes that program's
|
|
168
|
+
* call, the words before `xargs` kept: its input then only adds arguments, so the program counts like any other call
|
|
169
|
+
* and the text around it is no sign. Without a program it becomes `echo`, which `xargs` then runs. Every other
|
|
170
|
+
* command comes back as it is, so `xargs` stays opaque for a program from an expansion or a replace string, a glob,
|
|
171
|
+
* a guarded program, a starter (a shell, `env`, `xargs`, `timeout`, an interpreter) and any program not on the list.
|
|
172
|
+
*/
|
|
173
|
+
export const resolvePlainXargs = (words: SimpleCommand, guardedPrograms: readonly string[]): SimpleCommand => {
|
|
174
|
+
const start = ruleStart(words)
|
|
175
|
+
if (words[start] === undefined || programName(words[start]) !== XARGS) return words
|
|
176
|
+
const call = readXargsCall(words.slice(start + 1))
|
|
177
|
+
if (call === undefined) return words
|
|
178
|
+
const prefix = words.slice(0, start)
|
|
179
|
+
if (call.program === undefined) return [...prefix, ECHO]
|
|
180
|
+
if (!isPlainProgram(call.program, call.replaceStrings, guardedPrograms)) return words
|
|
181
|
+
return [...prefix, ...words.slice(start + 1 + call.at)]
|
|
182
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const DEFAULT_BASE_BRANCH = 'main'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A branch name both guards accept as the base branch: no option (`-x`), no `HEAD`, none of the forms git refuses
|
|
5
|
+
* (`..`, `//`, `@{`, a `.lock` component, a trailing `.` or `/`), only characters a project plausibly names one with.
|
|
6
|
+
*/
|
|
7
|
+
const BRANCH =/^(?!HEAD$)(?![-/.])(?!.*(?:\.\.|\/\/|@\{|\.lock(?:\/|$)))[A-Za-z0-9._/-]+(?<![./])$/
|
|
8
|
+
|
|
9
|
+
const BRANCH_REF = /^refs\/heads\//
|
|
10
|
+
|
|
11
|
+
const isBranchName = (value: unknown): value is string => typeof value === 'string' && BRANCH.test(value)
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The base branch a project config names, `refs/heads/<name>` read as `<name>`: `main` when it names none, undefined
|
|
15
|
+
* when the config could not be read (`null`) or the value is no branch name.
|
|
16
|
+
*/
|
|
17
|
+
export const baseBranchOf = (config: Record<string, unknown> | null): string | undefined => {
|
|
18
|
+
if (config === null) return undefined
|
|
19
|
+
if (!('baseBranch' in config)) return DEFAULT_BASE_BRANCH
|
|
20
|
+
const value = config.baseBranch
|
|
21
|
+
const name = typeof value === 'string' ? value.replace(BRANCH_REF, '') : value
|
|
22
|
+
return isBranchName(name) ? name : undefined
|
|
23
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lanes table of a board, read the way the herd reads it (`parseSprintLanes` in the package), which a hook cannot
|
|
3
|
+
* import: the first table under `## <lanes>`, a row naming its lane in backticks or continuing the one above with `↳`,
|
|
4
|
+
* the plan as the link in the third cell. A test compares both on the repository's real boards.
|
|
5
|
+
*/
|
|
6
|
+
import { posix } from 'node:path'
|
|
7
|
+
|
|
8
|
+
export type BoardLanguage = 'de' | 'en'
|
|
9
|
+
|
|
10
|
+
/** The plan vocabulary's words for the lanes section and a closed lane, per plan language. */
|
|
11
|
+
export const BOARD_WORDS: Record<BoardLanguage, { lanes: string; laneClosed: string }> = {
|
|
12
|
+
de: { lanes: 'Spuren', laneClosed: 'abgeräumt' },
|
|
13
|
+
en: { lanes: 'Lanes', laneClosed: 'cleaned up' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A lane's rows: whether any says the lane was cleaned up, and the plans they link, relative to the repository. */
|
|
17
|
+
export type LanePlans = { closed: boolean; plans: string[] }
|
|
18
|
+
|
|
19
|
+
type Row = { lane: string; cells: string[] }
|
|
20
|
+
|
|
21
|
+
const LANE_NAME = /`([^`]+)`/
|
|
22
|
+
const CONTINUATION = '↳'
|
|
23
|
+
const PLAN_LINK = /\[([^\]]+)\]\(([^)]+)\)/
|
|
24
|
+
const SECTION_END = /^##\s/
|
|
25
|
+
const TABLE_SEPARATOR = /^\|[\s|:-]+\|$/
|
|
26
|
+
const PLAN_COLUMN = 2
|
|
27
|
+
|
|
28
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
29
|
+
|
|
30
|
+
const sectionLines = (markdown: string, heading: string): string[] => {
|
|
31
|
+
const lines = markdown.split('\n')
|
|
32
|
+
const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`)
|
|
33
|
+
const start = lines.findIndex((line) => pattern.test(line))
|
|
34
|
+
if (start === -1) return []
|
|
35
|
+
const rest = lines.slice(start + 1)
|
|
36
|
+
const end = rest.findIndex((line) => SECTION_END.test(line))
|
|
37
|
+
return end === -1 ? rest : rest.slice(0, end)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cellsOf = (line: string): string[] =>
|
|
41
|
+
line
|
|
42
|
+
.slice(1, line.endsWith('|') ? -1 : undefined)
|
|
43
|
+
.split('|')
|
|
44
|
+
.map((cell) => cell.trim())
|
|
45
|
+
|
|
46
|
+
/** The body rows of the first table, header and separator rows dropped. */
|
|
47
|
+
const firstTableLines = (lines: string[]): string[] => {
|
|
48
|
+
const start = lines.findIndex((line) => line.startsWith('|'))
|
|
49
|
+
if (start === -1) return []
|
|
50
|
+
const rest = lines.slice(start)
|
|
51
|
+
const end = rest.findIndex((line) => !line.startsWith('|'))
|
|
52
|
+
return (end === -1 ? rest : rest.slice(0, end)).slice(1).filter((line) => !TABLE_SEPARATOR.test(line))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const laneRows = (markdown: string, language: BoardLanguage): Row[] => {
|
|
56
|
+
const rows: Row[] = []
|
|
57
|
+
let previous: string | null = null
|
|
58
|
+
for (const line of firstTableLines(sectionLines(markdown, BOARD_WORDS[language].lanes))) {
|
|
59
|
+
const cells = cellsOf(line)
|
|
60
|
+
const first = cells[0] ?? ''
|
|
61
|
+
const lane: string | null = first.match(LANE_NAME)?.[1] ?? (first.startsWith(CONTINUATION) ? previous : null)
|
|
62
|
+
if (lane === null) continue
|
|
63
|
+
rows.push({ lane, cells })
|
|
64
|
+
previous = lane
|
|
65
|
+
}
|
|
66
|
+
return rows
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The repository path of a plan link, relative to the board's directory. */
|
|
70
|
+
const planPathOf = (boardDirectory: string, cell: string): string[] => {
|
|
71
|
+
const href = cell.match(PLAN_LINK)?.[2]
|
|
72
|
+
return href === undefined ? [] : [posix.normalize(posix.join(boardDirectory, href.split('#')[0]))]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every lane the lanes table names, in table order. */
|
|
76
|
+
export const laneNamesOf = (markdown: string, language: BoardLanguage): string[] => [
|
|
77
|
+
...new Set(laneRows(markdown, language).map(({ lane }) => lane)),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
/** The rows of `lane` in the board's lanes table; undefined when the table has no row for it. */
|
|
81
|
+
export const lanePlansOf = (
|
|
82
|
+
markdown: string,
|
|
83
|
+
boardDirectory: string,
|
|
84
|
+
lane: string,
|
|
85
|
+
language: BoardLanguage,
|
|
86
|
+
): LanePlans | undefined => {
|
|
87
|
+
const rows = laneRows(markdown, language).filter((row) => row.lane === lane)
|
|
88
|
+
if (rows.length === 0) return undefined
|
|
89
|
+
return {
|
|
90
|
+
closed: rows.some(({ cells }) => (cells[0] ?? '').includes(BOARD_WORDS[language].laneClosed)),
|
|
91
|
+
plans: [...new Set(rows.flatMap(({ cells }) => planPathOf(boardDirectory, cells[PLAN_COLUMN] ?? '')))],
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isProgramPosition, programName } from './program-position'
|
|
2
|
+
import type { SimpleCommand, Word } from './shell'
|
|
3
|
+
|
|
4
|
+
/** Programs that run text they are given or read from elsewhere, so no parse of the shell sees what runs. */
|
|
5
|
+
const OPAQUE_RUNNERS = new Set([
|
|
6
|
+
'xargs',
|
|
7
|
+
'find',
|
|
8
|
+
'trap',
|
|
9
|
+
'coproc',
|
|
10
|
+
'repeat',
|
|
11
|
+
'script',
|
|
12
|
+
'su',
|
|
13
|
+
'flock',
|
|
14
|
+
'ssh',
|
|
15
|
+
'parallel',
|
|
16
|
+
'ionice',
|
|
17
|
+
'chrt',
|
|
18
|
+
'taskset',
|
|
19
|
+
'strace',
|
|
20
|
+
'unbuffer',
|
|
21
|
+
'chroot',
|
|
22
|
+
'runuser',
|
|
23
|
+
'systemd-run',
|
|
24
|
+
'nsenter',
|
|
25
|
+
'unshare',
|
|
26
|
+
'firejail',
|
|
27
|
+
'pkexec',
|
|
28
|
+
'entr',
|
|
29
|
+
'watchexec',
|
|
30
|
+
'busybox',
|
|
31
|
+
'watch',
|
|
32
|
+
'osascript',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
/** Package runners whose `-c`/`--call` hands a command string to a shell: `npx -c`, `npm exec -c`, `pnpm dlx -c`. */
|
|
36
|
+
const PACKAGE_RUNNERS = new Set(['npx', 'npm', 'pnpm', 'yarn'])
|
|
37
|
+
const CALL_OPTION = /^(-c|--call)(=|$)/
|
|
38
|
+
const AWK_PROGRAMS = new Set(['awk', 'gawk', 'mawk', 'nawk'])
|
|
39
|
+
/** An awk program that starts a command: `system(…)`, a pipe into or out of `getline`, `print … | "cmd"`. */
|
|
40
|
+
const AWK_RUNS_COMMAND = /\bsystem\s*\(|\|\s*getline|\|&|print[^;}]*\|/
|
|
41
|
+
/** sed's `e` command, alone or after an address: `1e cmd`, `/x/e cmd`, `;e`. */
|
|
42
|
+
const SED_EXECUTE_COMMAND = /(^|[\s;{}!=\d$/])e(\s|;|$)/
|
|
43
|
+
/** sed's `s///e` flag, which runs the pattern space as a command. */
|
|
44
|
+
const SED_EXECUTE_FLAG = /^\s*s(.)(?:\\.|(?!\1).)*\1(?:\\.|(?!\1).)*\1[^;\s}]*e/
|
|
45
|
+
|
|
46
|
+
const sedRunsCommand = (rest: Word[]): boolean =>
|
|
47
|
+
rest.some(
|
|
48
|
+
({ value }) => SED_EXECUTE_COMMAND.test(value) || value.split(/[;\n]/).some((piece) => SED_EXECUTE_FLAG.test(piece)),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const runsCommandText = (name: string, rest: Word[]): boolean =>
|
|
52
|
+
OPAQUE_RUNNERS.has(name) ||
|
|
53
|
+
(PACKAGE_RUNNERS.has(name) && rest.some(({ value }) => CALL_OPTION.test(value))) ||
|
|
54
|
+
(name === 'sed' && sedRunsCommand(rest)) ||
|
|
55
|
+
(AWK_PROGRAMS.has(name) && rest.some(({ value }) => AWK_RUNS_COMMAND.test(value)))
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The index of a program that runs command text it is given (`watch '…'`, `script -qc '…'`, `npx -c '…'`, sed's `e`,
|
|
59
|
+
* awk's `system()`), anywhere a program can stand; -1 when there is none.
|
|
60
|
+
*/
|
|
61
|
+
export const commandTextRunnerIndex = (words: SimpleCommand): number =>
|
|
62
|
+
words.findIndex(
|
|
63
|
+
(word, index) => isProgramPosition(words, index) && runsCommandText(programName(word), words.slice(index + 1)),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The text a mention search reads: the script as written and, beside it, every word as the shell decodes it, so a word
|
|
68
|
+
* split by quotes (`pu""sh`) or written in `$'…'` escapes (`pu\x73h`) is found too.
|
|
69
|
+
*/
|
|
70
|
+
export const searchableText = (script: string, commands: SimpleCommand[]): string =>
|
|
71
|
+
[script, ...commands.map((words) => words.map(({ value }) => value).join(' '))].join('\n')
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Long enough for a local `git` or `herdr` answer, well below the deadline of the hook that waits for it. */
|
|
2
|
+
export const COMMAND_TIMEOUT_MS = 3000
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The trimmed stdout of a command run without a shell; throws when it fails or outlives `COMMAND_TIMEOUT_MS`, so the
|
|
6
|
+
* caller gives up. A command past the limit is killed with SIGKILL, which it cannot ignore.
|
|
7
|
+
*/
|
|
2
8
|
export const commandOutput = (command: string[], cwd?: string): string => {
|
|
3
|
-
const result = Bun.spawnSync(command, {
|
|
9
|
+
const result = Bun.spawnSync(command, {
|
|
10
|
+
cwd,
|
|
11
|
+
stdout: 'pipe',
|
|
12
|
+
stderr: 'ignore',
|
|
13
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
14
|
+
killSignal: 'SIGKILL',
|
|
15
|
+
})
|
|
4
16
|
if (result.exitCode !== 0) throw new Error(`${command[0]} failed`)
|
|
5
17
|
return result.stdout.toString().trim()
|
|
6
18
|
}
|
|
@@ -3,6 +3,9 @@ import { isRecord, type JsonObject } from './json'
|
|
|
3
3
|
|
|
4
4
|
export type { DialogAnswer } from './dialog-answer'
|
|
5
5
|
|
|
6
|
+
/** The tool name of the dialog, as hook inputs and transcripts carry it. */
|
|
7
|
+
export const ASK_USER_QUESTION_TOOL = 'AskUserQuestion'
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* The answers of the user's dialogs, read from a session transcript. The transcript is written by the agent's own
|
|
8
11
|
* client, not by the agent: a dialog answer sits in it as the result of an `AskUserQuestion` call, with the question the
|
|
@@ -49,7 +52,7 @@ const askedQuestions = (entries: JsonObject[]): Map<string, AskedQuestion[]> =>
|
|
|
49
52
|
entries
|
|
50
53
|
.filter(isMainThread)
|
|
51
54
|
.flatMap(contentOf)
|
|
52
|
-
.filter((block) => block.type === 'tool_use' && block.name ===
|
|
55
|
+
.filter((block) => block.type === 'tool_use' && block.name === ASK_USER_QUESTION_TOOL && typeof block.id === 'string')
|
|
53
56
|
.map((block): [string, AskedQuestion[]] => [block.id as string, questionsOf(block.input)]),
|
|
54
57
|
)
|
|
55
58
|
|
|
@@ -2,3 +2,13 @@ export type JsonObject = Record<string, unknown>
|
|
|
2
2
|
|
|
3
3
|
export const isRecord = (value: unknown): value is JsonObject =>
|
|
4
4
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
5
|
+
|
|
6
|
+
/** The JSON text as an object; undefined for text that is no JSON or no object (an array included). */
|
|
7
|
+
export const parseJsonObject = (text: string): JsonObject | undefined => {
|
|
8
|
+
try {
|
|
9
|
+
const value: unknown = JSON.parse(text)
|
|
10
|
+
return isRecord(value) ? value : undefined
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
2
|
+
import type { PaneAgent } from './pane-agent'
|
|
3
|
+
|
|
4
|
+
/** herdr's agent names; a worktree whose directory has another name cannot be a lane. */
|
|
5
|
+
const LANE_NAME = /^[a-z][a-z0-9_-]{0,31}$/
|
|
6
|
+
|
|
7
|
+
export type LaneLocation = { cwd: string; main: string; worktreeDirectory: string }
|
|
8
|
+
|
|
9
|
+
export type AgentLocation = {
|
|
10
|
+
/** The agent herdr runs in the pane the hook was called from. */
|
|
11
|
+
agent: PaneAgent
|
|
12
|
+
/** The session the hook was called for. */
|
|
13
|
+
session: string
|
|
14
|
+
main: string
|
|
15
|
+
worktreeDirectory: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The session that merges, against the agent herdr runs in its pane. */
|
|
19
|
+
export type MergingSession = {
|
|
20
|
+
/** The agent of the pane the hook runs in; null outside herdr. */
|
|
21
|
+
agent: PaneAgent | null
|
|
22
|
+
/** The `session_id` of the hook input. */
|
|
23
|
+
session: unknown
|
|
24
|
+
main: string
|
|
25
|
+
/** Whether a herdr name is a lane's, whose worktree exists. */
|
|
26
|
+
isLane: (name: string) => boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The lane whose worktree holds `cwd`: the first directory below the main checkout's worktree directory, else null. */
|
|
30
|
+
export const laneOf = ({ cwd, main, worktreeDirectory }: LaneLocation): string | null => {
|
|
31
|
+
const path = relative(resolve(main, worktreeDirectory), resolve(cwd))
|
|
32
|
+
if (path === '' || path.startsWith('..') || isAbsolute(path)) return null
|
|
33
|
+
const [lane] = path.split(sep)
|
|
34
|
+
return LANE_NAME.test(lane) ? lane : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The lane the agent of this pane is: an agent named after a worktree that it runs in, in the session the hook was
|
|
39
|
+
* called for. The shell's directory has no say: the coordinator's shell may stand in a lane's worktree.
|
|
40
|
+
*/
|
|
41
|
+
export const laneOfAgent = ({ agent, session, main, worktreeDirectory }: AgentLocation): string | null => {
|
|
42
|
+
if (agent.name === null || agent.cwd === null || agent.session === null || agent.session !== session) return null
|
|
43
|
+
return laneOf({ cwd: agent.cwd, main, worktreeDirectory }) === agent.name ? agent.name : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether the session that merges is the coordinator: the agent of its pane, in this very session, started in the main
|
|
48
|
+
* checkout and not under a lane's name. A lane's transcript may lie beside the coordinator's when it was started in the
|
|
49
|
+
* main checkout; its name still tells it apart, so the plan approval never covers a lane's own merge.
|
|
50
|
+
*/
|
|
51
|
+
export const isCoordinatorAgent = ({ agent, session, main, isLane }: MergingSession): boolean =>
|
|
52
|
+
agent !== null &&
|
|
53
|
+
agent.session !== null &&
|
|
54
|
+
agent.session === session &&
|
|
55
|
+
agent.cwd !== null &&
|
|
56
|
+
resolve(agent.cwd) === resolve(main) &&
|
|
57
|
+
(agent.name === null || !isLane(agent.name))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The value of `compute`, computed at the first call and kept for every later one, undefined and throws aside. */
|
|
2
|
+
export const once = <T>(compute: () => T): (() => T) => {
|
|
3
|
+
let kept: { value: T } | undefined
|
|
4
|
+
return () => {
|
|
5
|
+
kept ??= { value: compute() }
|
|
6
|
+
return kept.value
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type PaneAgent, paneAgentOf } from './pane-agent'
|
|
2
|
+
|
|
3
|
+
/** herdr's pane ids (`w2V:p1`); anything else never reaches the command line. */
|
|
4
|
+
const PANE_ID = /^[A-Za-z0-9][A-Za-z0-9:_-]{0,63}$/
|
|
5
|
+
/** Well below the deadlines of the hooks that ask, which still have work to do after it. */
|
|
6
|
+
const HERDR_TIMEOUT_MS = 2000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The agent herdr runs in the pane this process was started in; null outside herdr and whenever herdr cannot be asked.
|
|
10
|
+
*
|
|
11
|
+
* Only the pane is fixed: herdr sets `HERDR_PANE_ID` in the client's environment, and no command the agent runs can
|
|
12
|
+
* change it there. herdr's answer about that pane is not: the agent's name and session are reported to herdr, and any
|
|
13
|
+
* process may report them. Only `cwd` herdr reads from the process itself. A caller trusts a name or a session only
|
|
14
|
+
* where a second fact binds it, such as the session of the hook input or a worktree of that name.
|
|
15
|
+
*/
|
|
16
|
+
export const ownPaneAgent = (env: Record<string, string | undefined> = process.env): PaneAgent | null => {
|
|
17
|
+
const paneId = env.HERDR_PANE_ID
|
|
18
|
+
if (paneId === undefined || !PANE_ID.test(paneId)) return null
|
|
19
|
+
try {
|
|
20
|
+
const result = Bun.spawnSync(['herdr', 'agent', 'get', paneId], {
|
|
21
|
+
stdin: 'ignore',
|
|
22
|
+
stdout: 'pipe',
|
|
23
|
+
stderr: 'ignore',
|
|
24
|
+
timeout: HERDR_TIMEOUT_MS,
|
|
25
|
+
env,
|
|
26
|
+
})
|
|
27
|
+
return result.exitCode === 0 ? paneAgentOf(JSON.parse(result.stdout.toString())) : null
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { isRecord, type JsonObject } from './json'
|
|
2
|
+
|
|
3
|
+
/** The agent herdr runs in a pane: its herdr name, the directory it started in and its client session, each null when unknown. */
|
|
4
|
+
export type PaneAgent = { name: string | null; cwd: string | null; session: string | null }
|
|
5
|
+
|
|
6
|
+
/** A client's session id as a UUID; nothing else may reach a path. */
|
|
7
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
8
|
+
|
|
9
|
+
/** The client session of an agent or a pane herdr reports; null for a shell, or a session id of another shape. */
|
|
10
|
+
export const sessionOf = (pane: JsonObject): string | null => {
|
|
11
|
+
const session = isRecord(pane.agent_session) ? pane.agent_session.value : undefined
|
|
12
|
+
return typeof pane.agent === 'string' && pane.agent !== '' && typeof session === 'string' && SESSION_ID.test(session)
|
|
13
|
+
? session
|
|
14
|
+
: null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const textOrNull = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
|
|
18
|
+
|
|
19
|
+
/** The agent of the answer of `herdr agent get <pane>`; null for an error or an answer of another shape. */
|
|
20
|
+
export const paneAgentOf = (answer: unknown): PaneAgent | null => {
|
|
21
|
+
const agent = isRecord(answer) && isRecord(answer.result) ? answer.result.agent : undefined
|
|
22
|
+
if (!isRecord(agent)) return null
|
|
23
|
+
return { name: textOrNull(agent.name), cwd: textOrNull(agent.cwd), session: sessionOf(agent) }
|
|
24
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a plan still has an open phase, read the way the herd reads phases (`parsePlanPhases` in the package),
|
|
3
|
+
* which a hook cannot import. A test compares both on the repository's real plans.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A task of a phase, a checkbox or a row with a ⬜/✅ cell: whether it is open, and its text without the state. */
|
|
7
|
+
export type PlanTask = { open: boolean; text: string }
|
|
8
|
+
|
|
9
|
+
type Phase = { id: string; items: boolean[]; deferredOpen: boolean; pointer?: string }
|
|
10
|
+
|
|
11
|
+
/** A phase's id, its heading without the leading hashes, and its lines up to the next heading that ends it. */
|
|
12
|
+
export type PhaseBlock = { id: string; heading: string; lines: string[] }
|
|
13
|
+
|
|
14
|
+
const PHASE_HEADING = /^###\s+Phase\s+(\d+[a-z]?)\b/i
|
|
15
|
+
/** `## 10 — title`, and `## P15 — title` as a board names the phase. */
|
|
16
|
+
const NUMBERED_SECTION = /^##\s+P?(\d+[a-z]?)\s*[—–:-]\s*(.*)$/
|
|
17
|
+
const PHASE_END = /^#{1,3}\s/
|
|
18
|
+
const SECTION_HEADING = /^##\s+(.+?)\s*$/
|
|
19
|
+
const TOP_HEADING = /^#\s/
|
|
20
|
+
const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/
|
|
21
|
+
const DEFERRED_TO = /\s→\s*P(\d+[a-z]?)\s*$/i
|
|
22
|
+
const SECTION_POINTER = /`##\s+([^`]+?)\s*`/
|
|
23
|
+
const STATUS = { done: '✅', open: '⬜' } as const
|
|
24
|
+
|
|
25
|
+
const collapsed = (text: string): string => text.trim().replace(/\s+/g, ' ')
|
|
26
|
+
|
|
27
|
+
/** The task a line holds, null for a line that is none. */
|
|
28
|
+
export const taskOf = (line: string): PlanTask | null => {
|
|
29
|
+
const checkbox = line.match(CHECKBOX)
|
|
30
|
+
if (checkbox) return { open: checkbox[1] === ' ', text: collapsed(checkbox[2]) }
|
|
31
|
+
const trimmed = line.trim()
|
|
32
|
+
if (!trimmed.startsWith('|')) return null
|
|
33
|
+
const cells = trimmed.replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim())
|
|
34
|
+
const status = cells.find((cell) => cell === STATUS.done || cell === STATUS.open)
|
|
35
|
+
if (status === undefined) return null
|
|
36
|
+
const text = cells.filter((cell) => cell !== STATUS.done && cell !== STATUS.open).join(' | ')
|
|
37
|
+
return { open: status === STATUS.open, text: collapsed(text) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** An item of a phase: `true` while open, `false` when done, null for a line that is none. */
|
|
41
|
+
const itemOf = (line: string): boolean | null => taskOf(line)?.open ?? null
|
|
42
|
+
|
|
43
|
+
/** An open checkbox moved on to another phase with `→ P<n>` at its end; it stays open, in that phase. */
|
|
44
|
+
const isDeferred = (line: string, phaseId: string): boolean => {
|
|
45
|
+
const checkbox = line.match(CHECKBOX)
|
|
46
|
+
const target = checkbox?.[1] === ' ' ? checkbox[2].match(DEFERRED_TO)?.[1]?.toLowerCase() : undefined
|
|
47
|
+
return target !== undefined && target !== phaseId
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const phaseIdOf = (line: string): string | undefined =>
|
|
51
|
+
(line.match(PHASE_HEADING) ?? line.match(NUMBERED_SECTION))?.[1]?.toLowerCase()
|
|
52
|
+
|
|
53
|
+
const fileLine = (phase: Phase, line: string): void => {
|
|
54
|
+
phase.pointer ??= line.match(SECTION_POINTER)?.[1]
|
|
55
|
+
if (isDeferred(line, phase.id)) {
|
|
56
|
+
phase.deferredOpen = true
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
const item = itemOf(line)
|
|
60
|
+
if (item !== null) phase.items.push(item)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The phases of a plan as they stand, `### Phase <id>` and `## <id> — title` alike. */
|
|
64
|
+
export const phaseBlocksOf = (markdown: string): PhaseBlock[] => {
|
|
65
|
+
const blocks: PhaseBlock[] = []
|
|
66
|
+
let open: PhaseBlock | null = null
|
|
67
|
+
for (const line of markdown.split('\n')) {
|
|
68
|
+
const id = phaseIdOf(line)
|
|
69
|
+
if (id !== undefined) {
|
|
70
|
+
open = { id, heading: collapsed(line.replace(/^#+/, '')), lines: [] }
|
|
71
|
+
blocks.push(open)
|
|
72
|
+
} else if (PHASE_END.test(line)) open = null
|
|
73
|
+
else open?.lines.push(line)
|
|
74
|
+
}
|
|
75
|
+
return blocks
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const phasesOf = (markdown: string): Phase[] =>
|
|
79
|
+
phaseBlocksOf(markdown).map(({ id, lines }) => {
|
|
80
|
+
const phase: Phase = { id, items: [], deferredOpen: false }
|
|
81
|
+
for (const line of lines) fileLine(phase, line)
|
|
82
|
+
return phase
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
/** The items of every `## ` section by heading, sub-headings included; the last of equal headings wins. */
|
|
86
|
+
const sectionItems = (markdown: string): Map<string, boolean[]> => {
|
|
87
|
+
const sections = new Map<string, boolean[]>()
|
|
88
|
+
let open: boolean[] | null = null
|
|
89
|
+
for (const line of markdown.split('\n')) {
|
|
90
|
+
const heading = line.match(SECTION_HEADING)
|
|
91
|
+
if (heading) {
|
|
92
|
+
open = []
|
|
93
|
+
sections.set(heading[1], open)
|
|
94
|
+
} else if (TOP_HEADING.test(line)) open = null
|
|
95
|
+
else {
|
|
96
|
+
const item = itemOf(line)
|
|
97
|
+
if (item !== null) open?.push(item)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return sections
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* True when a phase (`### Phase <id>` or `## <id> — title`) holds an open checkbox or ⬜ row, a deferred one included;
|
|
105
|
+
* a phase without items of its own counts the section it points at (``Siehe `## Dokumentation` ``).
|
|
106
|
+
*/
|
|
107
|
+
export const hasOpenPhase = (markdown: string): boolean => {
|
|
108
|
+
const phases = phasesOf(markdown)
|
|
109
|
+
if (phases.some((phase) => phase.deferredOpen || phase.items.includes(true))) return true
|
|
110
|
+
const pointed = phases.filter((phase) => phase.items.length === 0 && phase.pointer !== undefined)
|
|
111
|
+
if (pointed.length === 0) return false
|
|
112
|
+
const sections = sectionItems(markdown)
|
|
113
|
+
return pointed.some((phase) => (sections.get(phase.pointer ?? '') ?? []).includes(true))
|
|
114
|
+
}
|