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.
Files changed (228) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +65 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
  5. package/dist/config.d.ts +99 -4
  6. package/dist/config.js +105 -29
  7. package/dist/plan-format.d.ts +51 -6
  8. package/dist/plan-format.js +49 -7
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +57 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/rules/rules/kit/pull-requests.md +2 -0
  50. package/kit/workflow/skills/follow/SKILL.md +3 -10
  51. package/kit/workflow/skills/implement/SKILL.md +8 -6
  52. package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
  53. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  54. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  55. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  56. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  57. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  58. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  59. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  60. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  61. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  62. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  64. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  65. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  66. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  67. package/kit/workflow/skills/plan/SKILL.md +6 -5
  68. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  69. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  70. package/package.json +3 -1
  71. package/recipes/README.md +2 -1
  72. package/recipes/sources.md +5 -1
  73. package/schemas/jackherd.schema.json +139 -5
  74. package/src/check/agent-text-reviewers.ts +23 -0
  75. package/src/check/command.ts +2 -0
  76. package/src/check/plan-structure-check.ts +49 -2
  77. package/src/check/plan.ts +8 -1
  78. package/src/check/schema.ts +9 -0
  79. package/src/check/skill-budget-defaults.ts +55 -0
  80. package/src/check/skill-budgets.ts +185 -0
  81. package/src/cli.ts +2 -0
  82. package/src/config/auto-window.ts +8 -0
  83. package/src/config/link-kinds.ts +7 -1
  84. package/src/config/schema.ts +82 -25
  85. package/src/config/tones.ts +27 -0
  86. package/src/lane/close-board.ts +106 -0
  87. package/src/lane/close-check.ts +183 -0
  88. package/src/lane/close-command-line.ts +11 -0
  89. package/src/lane/close-command.ts +217 -0
  90. package/src/lane/close-options.ts +43 -0
  91. package/src/lane/close-survey.ts +83 -0
  92. package/src/lane/close.ts +141 -0
  93. package/src/lane/command.ts +13 -0
  94. package/src/lane-prompt/additions.ts +14 -0
  95. package/src/lane-prompt/build.ts +74 -0
  96. package/src/lane-prompt/command.ts +93 -0
  97. package/src/lane-prompt/input.ts +157 -0
  98. package/src/lane-prompt/options.ts +70 -0
  99. package/src/lane-prompt/texts.ts +101 -0
  100. package/src/mermaid/command.ts +36 -0
  101. package/src/mermaid/problems.ts +48 -0
  102. package/src/plan-format/clock.ts +8 -0
  103. package/src/plan-format/dependencies.ts +9 -6
  104. package/src/plan-format/index.ts +1 -0
  105. package/src/plan-format/short-names.ts +71 -0
  106. package/src/plan-format/waiting.ts +22 -4
  107. package/src/plans/appointment-rows.ts +83 -0
  108. package/src/plans/ask-coordinator.ts +111 -0
  109. package/src/plans/ask-target.ts +36 -0
  110. package/src/plans/board-close.ts +85 -0
  111. package/src/plans/child-stop.ts +51 -0
  112. package/src/plans/closable-records.ts +34 -0
  113. package/src/plans/command.ts +3 -2
  114. package/src/plans/fetch.ts +26 -3
  115. package/src/plans/frame.ts +5 -12
  116. package/src/plans/git.ts +54 -5
  117. package/src/plans/groomed.ts +27 -0
  118. package/src/plans/herd-claim.ts +173 -0
  119. package/src/plans/herd-memory.ts +36 -25
  120. package/src/plans/lane-plans.ts +44 -13
  121. package/src/plans/lane-summary.ts +253 -45
  122. package/src/plans/layout-phase.ts +36 -11
  123. package/src/plans/layout-summary.ts +89 -18
  124. package/src/plans/layout.ts +502 -98
  125. package/src/plans/link-chips.ts +23 -4
  126. package/src/plans/live.ts +178 -23
  127. package/src/plans/next-rows.ts +44 -0
  128. package/src/plans/phase-ids.ts +8 -0
  129. package/src/plans/phase-source.ts +15 -0
  130. package/src/plans/plan-cache.ts +28 -0
  131. package/src/plans/plan-heads.ts +54 -0
  132. package/src/plans/planned.ts +22 -0
  133. package/src/plans/pull-requests.ts +17 -2
  134. package/src/plans/question-records.ts +48 -0
  135. package/src/plans/questions-read.ts +13 -0
  136. package/src/plans/read-cache.ts +8 -0
  137. package/src/plans/record-id.ts +4 -0
  138. package/src/plans/restart-decision.ts +13 -4
  139. package/src/plans/row-actions.ts +52 -0
  140. package/src/plans/row-focus.ts +148 -0
  141. package/src/plans/row-targets.ts +7 -0
  142. package/src/plans/selection.ts +111 -18
  143. package/src/plans/sent-files-read.ts +6 -15
  144. package/src/plans/sent-files.ts +7 -14
  145. package/src/plans/settings.ts +12 -1
  146. package/src/plans/signalled-phase.ts +42 -0
  147. package/src/plans/signals-read.ts +12 -4
  148. package/src/plans/snapshot.ts +162 -81
  149. package/src/plans/source-asked.ts +19 -0
  150. package/src/plans/source-prefix.ts +17 -0
  151. package/src/plans/source-rows.ts +188 -0
  152. package/src/plans/sprint-export.ts +11 -11
  153. package/src/plans/sprint-page.ts +5 -5
  154. package/src/plans/subagent-phases.ts +95 -0
  155. package/src/plans/subagent-tasks.ts +51 -0
  156. package/src/plans/subagents-read.ts +66 -17
  157. package/src/plans/subagents.ts +64 -6
  158. package/src/plans/supervisor.ts +14 -4
  159. package/src/plans/symbols.ts +3 -0
  160. package/src/plans/task-name.ts +34 -0
  161. package/src/plans/texts.ts +48 -6
  162. package/src/plans/tones.ts +30 -0
  163. package/src/plans/view.ts +25 -11
  164. package/src/plans/waiting-click.ts +295 -35
  165. package/src/plans/waiting-records.ts +39 -0
  166. package/src/plans/waiting-window.ts +8 -0
  167. package/src/preview/command.ts +25 -3
  168. package/src/shared/private-location.ts +89 -0
  169. package/src/shared/stat-cache.ts +48 -0
  170. package/src/sync/kit-source.ts +29 -10
  171. package/src/sync/kit.ts +2 -0
  172. package/src/sync/manifest.ts +2 -0
  173. package/src/sync/plan-ask-rules.ts +94 -0
  174. package/src/sync/plan-hooks.ts +2 -0
  175. package/src/sync/render.ts +3 -3
  176. package/src/sync/settings-hooks.ts +4 -1
  177. package/src/sync/sync-plan.ts +1 -0
  178. package/src/sync/sync.ts +24 -3
  179. package/src/task-signals/claude-read.ts +27 -10
  180. package/src/terminal/text.ts +16 -3
  181. package/src/terminal/types.ts +4 -0
  182. package/src/version/skill-diff.ts +46 -0
  183. package/src/vocabulary/index.ts +26 -6
  184. package/src/waiting/command.ts +22 -7
  185. package/src/watch/appointments.ts +1 -2
  186. package/src/watch/asking-lanes.ts +40 -0
  187. package/src/watch/base-tip.ts +11 -0
  188. package/src/watch/command.ts +330 -105
  189. package/src/watch/heartbeat-progress.ts +6 -18
  190. package/src/watch/kit-update.ts +28 -9
  191. package/src/watch/lane-name.ts +6 -0
  192. package/src/watch/lane-progress.ts +40 -8
  193. package/src/watch/lane-report-file.ts +20 -5
  194. package/src/watch/lane-report.ts +32 -4
  195. package/src/watch/lane-sources.ts +31 -3
  196. package/src/watch/main-checkout.ts +34 -0
  197. package/src/watch/plan-badges-read.ts +22 -9
  198. package/src/watch/plan-badges.ts +10 -2
  199. package/src/watch/pull-requests.ts +6 -2
  200. package/src/watch/reference-paths.ts +87 -0
  201. package/src/watch/report-questions.ts +4 -3
  202. package/src/watch/running-subagents.ts +43 -0
  203. package/src/watch/skill-change.ts +124 -32
  204. package/src/watch/source-change.ts +48 -0
  205. package/src/watch/source-entries.ts +55 -0
  206. package/src/watch/source-fields.ts +1 -0
  207. package/src/watch/source-statuses.ts +60 -0
  208. package/src/watch/source-tickets.ts +21 -1
  209. package/src/watch/sources.ts +47 -4
  210. package/src/watch/stream-output.ts +4 -3
  211. package/src/watch/task-parallel-auto.ts +208 -0
  212. package/src/watch/task-parallel-data-paths.ts +46 -0
  213. package/src/watch/task-parallel-input.ts +5 -15
  214. package/src/watch/task-parallel-read.ts +3 -1
  215. package/src/watch/task-parallel.ts +36 -7
  216. package/src/watch/task-signal-missing.ts +69 -0
  217. package/src/watch/task-signal-read.ts +29 -0
  218. package/src/watch/texts.ts +10 -0
  219. package/src/watch/update-available.ts +6 -3
  220. package/src/watch/watch-events.ts +23 -0
  221. package/src/watch/watcher-lock.ts +25 -29
  222. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  223. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  224. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  225. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  226. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  227. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  228. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,194 +0,0 @@
1
- /**
2
- * A plan approval outlives the session it was given in: the `PostToolUse` hook on `AskUserQuestion` leaves a pointer
3
- * (plan, transcript file, `tool_use_id`) under `.jackherd/approvals/plans/`, and the merge guard follows it back into
4
- * that transcript at every merge. The pointer only says where to look; what counts is the dialog it finds there, the
5
- * question naming the plan and the label the user picked, so a pointer an agent writes itself gains nothing.
6
- *
7
- * The limit, as for the branch approval: an agent that runs as the user could still write a transcript file. This
8
- * guards against a mistake, not against an agent that sets out to get around it.
9
- */
10
- import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
11
- import { dirname, isAbsolute, join } from 'node:path'
12
- import { ASK_USER_QUESTION_TOOL, dialogAnswers } from '../shared/dialog-answers'
13
- import { realPathOrNull } from '../shared/files'
14
- import { isRecord } from '../shared/json'
15
- import { ensurePrivateDirectory, PRIVATE_FILE } from '../shared/private-directory'
16
- import type { PlanAnswer } from './plan-approval'
17
-
18
- /**
19
- * `answers`: per plan, the answer the hook input reported, null where it reported none. An approval still needs its
20
- * dialog in the transcript; any other answer ends the approval at `recordedAt` without one, since it only adds a question.
21
- */
22
- export type PlanPointer = {
23
- toolUseId: string
24
- transcript: string
25
- plans: string[]
26
- answers: Record<string, string | null>
27
- recordedAt: number
28
- }
29
-
30
- export type PlanAnswerQuery = {
31
- /** The pointer directory, `.jackherd/approvals/plans` of the main checkout. */
32
- directory: string
33
- /** The transcript of the session that merges: only transcripts beside it, of the same project, are read. */
34
- currentTranscript: string | undefined
35
- plan: string
36
- /** The label that approves a plan; a pointer with any other answer is a revocation. */
37
- label: string
38
- }
39
-
40
- const PLAN_MENTION = /\bPlan `([^`]*)`/g
41
- const PLAN_PATH = /^[A-Za-z0-9_./-]+\.md$/
42
- const TOOL_USE_ID = /^[A-Za-z0-9_-]{1,128}$/
43
- const POINTER_FILE = /^([A-Za-z0-9_-]{1,128})\.json$/
44
- const TRANSCRIPT_FILE = /\.jsonl$/
45
-
46
- /** A plan path as a board links it: a relative markdown file, no empty, `.` or `..` segment. */
47
- const isPlanPath = (path: string): boolean =>
48
- PLAN_PATH.test(path) && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
49
-
50
- const isTranscriptPath = (value: unknown): value is string =>
51
- typeof value === 'string' && isAbsolute(value) && TRANSCRIPT_FILE.test(value)
52
-
53
- const isToolUseId = (value: unknown): value is string => typeof value === 'string' && TOOL_USE_ID.test(value)
54
-
55
- /** The one plan a question names as ``Plan `path` ``; a question that names none or several names none. */
56
- export const namedPlan = (question: string): string | undefined => {
57
- const plans = new Set([...question.matchAll(PLAN_MENTION)].map(([, path]) => path))
58
- const [only] = plans
59
- return plans.size === 1 && isPlanPath(only) ? only : undefined
60
- }
61
-
62
- type PlanAsked = { plan: string; question: string }
63
-
64
- /** The plans of the single-choice questions that name one and offer one of the plan labels, with the question text. */
65
- const plansAsked = (input: unknown, labels: readonly string[]): PlanAsked[] => {
66
- const questions = isRecord(input) && Array.isArray(input.questions) ? input.questions.filter(isRecord) : []
67
- return questions.flatMap((question) => {
68
- const options = question.multiSelect !== true && Array.isArray(question.options) ? question.options : []
69
- const offersLabel = options.some((option) => isRecord(option) && labels.includes(String(option.label)))
70
- const text = typeof question.question === 'string' ? question.question : ''
71
- const plan = namedPlan(text)
72
- return offersLabel && plan !== undefined ? [{ plan, question: text }] : []
73
- })
74
- }
75
-
76
- /** The answer the hook input reports for a question, keyed by its text; null when it reports none. */
77
- const reportedAnswer = (response: unknown, question: string): string | null => {
78
- const answers = isRecord(response) && isRecord(response.answers) ? response.answers : {}
79
- const answer = answers[question]
80
- return typeof answer === 'string' ? answer : null
81
- }
82
-
83
- /** The pointer for a `PostToolUse` input of an `AskUserQuestion` call that asked about a plan; undefined for any other. */
84
- export const pointerOf = (input: unknown, labels: readonly string[], now: number): PlanPointer | undefined => {
85
- if (!isRecord(input) || input.tool_name !== ASK_USER_QUESTION_TOOL) return undefined
86
- const { tool_use_id: toolUseId, transcript_path: transcript } = input
87
- if (!isToolUseId(toolUseId) || !isTranscriptPath(transcript)) return undefined
88
- const asked = plansAsked(input.tool_input, labels.filter(Boolean))
89
- if (asked.length === 0) return undefined
90
- const answers = Object.fromEntries(asked.map(({ plan, question }) => [plan, reportedAnswer(input.tool_response, question)]))
91
- return { toolUseId, transcript, plans: [...new Set(asked.map(({ plan }) => plan))], answers, recordedAt: now }
92
- }
93
-
94
- /** The lines of the file that contain `needle`: a transcript runs to hundreds of megabytes, and only these get parsed. */
95
- const linesContaining = (text: Buffer, needle: string): string[] => {
96
- const lines: string[] = []
97
- for (let at = text.indexOf(needle); at !== -1; ) {
98
- const start = text.lastIndexOf('\n', at) + 1
99
- const newline = text.indexOf('\n', at)
100
- const end = newline === -1 ? text.length : newline
101
- lines.push(text.subarray(start, end).toString('utf8'))
102
- at = newline === -1 ? -1 : text.indexOf(needle, end)
103
- }
104
- return lines
105
- }
106
-
107
- const parsedLines = (lines: string[]): unknown[] =>
108
- lines.flatMap((line) => {
109
- try {
110
- return [JSON.parse(line)]
111
- } catch {
112
- return []
113
- }
114
- })
115
-
116
- /** The answers of the dialog `toolUseId` in the transcript to questions naming `plan`, free text included. */
117
- const answersInTranscript = (text: Buffer, toolUseId: string, plan: string): PlanAnswer[] =>
118
- dialogAnswers(parsedLines(linesContaining(text, JSON.stringify(toolUseId))))
119
- .filter(({ question }) => namedPlan(question) === plan)
120
- .map(({ answer, answeredAt }) => ({ answer, answeredAt }))
121
-
122
- /** A pointer whose reported answer is not the label ends the approval when it was recorded; no transcript needed. */
123
- const revocationOf = (pointer: PlanPointer, plan: string): PlanAnswer => ({
124
- answer: pointer.answers[plan] ?? '',
125
- answeredAt: pointer.recordedAt,
126
- })
127
-
128
- /** The pointer directory of a main checkout, beside the approval gate's requests. */
129
- export const planPointersDirectory = (main: string): string => join(main, '.jackherd', 'approvals', 'plans')
130
-
131
- export const writePointer = (directory: string, pointer: PlanPointer): void => {
132
- ensurePrivateDirectory(directory)
133
- writeFileSync(join(directory, `${pointer.toolUseId}.json`), `${JSON.stringify(pointer)}\n`, { mode: PRIVATE_FILE })
134
- }
135
-
136
- const pointerIn = (directory: string, name: string): PlanPointer[] => {
137
- const id = POINTER_FILE.exec(name)?.[1]
138
- if (id === undefined) return []
139
- try {
140
- const value: unknown = JSON.parse(readFileSync(join(directory, name), 'utf8'))
141
- if (!isRecord(value) || value.toolUseId !== id || !isTranscriptPath(value.transcript)) return []
142
- const plans = Array.isArray(value.plans) ? value.plans : []
143
- const valid = plans.length > 0 && plans.every((plan) => typeof plan === 'string' && isPlanPath(plan))
144
- if (!valid || typeof value.recordedAt !== 'number') return []
145
- const answers = isRecord(value.answers) ? value.answers : {}
146
- const reported = (plan: string) => (typeof answers[plan] === 'string' ? answers[plan] : null)
147
- return [{ ...(value as PlanPointer), answers: Object.fromEntries(plans.map((plan) => [plan, reported(plan)])) }]
148
- } catch {
149
- return []
150
- }
151
- }
152
-
153
- /** Every file of the directory that parses as a pointer named after its own id; none for a missing directory. */
154
- export const readPointers = (directory: string): PlanPointer[] => {
155
- try {
156
- return readdirSync(directory).flatMap((name) => pointerIn(directory, name))
157
- } catch {
158
- return []
159
- }
160
- }
161
-
162
- const readOrNull = (path: string): Buffer | null => {
163
- try {
164
- return readFileSync(path)
165
- } catch {
166
- return null
167
- }
168
- }
169
-
170
- /** The answers a pointer's transcript holds for `plan`, when that transcript lies beside the current one. */
171
- const backedAnswers = (pointer: PlanPointer, plan: string, project: string, texts: Map<string, Buffer | null>) => {
172
- const path = realPathOrNull(pointer.transcript)
173
- if (path === null || dirname(path) !== project) return []
174
- if (!texts.has(path)) texts.set(path, readOrNull(path))
175
- const text = texts.get(path)
176
- return text ? answersInTranscript(text, pointer.toolUseId, plan) : []
177
- }
178
-
179
- /**
180
- * The answers to the plan questions naming `plan`. A pointer that reported the approval label counts only with its
181
- * dialog in a transcript beside the current one (Claude Code keeps a project's sessions side by side); a pointer that
182
- * reported anything else, free text or nothing included, is a revocation and counts as it stands.
183
- */
184
- export const planAnswers = ({ directory, currentTranscript, plan, label }: PlanAnswerQuery): PlanAnswer[] => {
185
- const pointers = readPointers(directory).filter((pointer) => pointer.plans.includes(plan))
186
- const revocations = pointers.filter((pointer) => pointer.answers[plan] !== label).map((pointer) => revocationOf(pointer, plan))
187
- const project = currentTranscript === undefined ? null : realPathOrNull(dirname(currentTranscript))
188
- if (project === null) return revocations
189
- const texts = new Map<string, Buffer | null>()
190
- const approvals = pointers
191
- .filter((pointer) => pointer.answers[plan] === label)
192
- .flatMap((pointer) => backedAnswers(pointer, plan, project, texts))
193
- return [...approvals, ...revocations]
194
- }
@@ -1,71 +0,0 @@
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,114 +0,0 @@
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
- }