jackherd 1.6.0 → 1.7.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 (152) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +260 -315
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/nested-quantifier.ts +73 -0
  81. package/src/config/repository-directory.ts +11 -0
  82. package/src/config/schema.ts +63 -8
  83. package/src/plan-format/board-prose.ts +36 -0
  84. package/src/plan-format/dependencies.ts +227 -0
  85. package/src/plan-format/documentation-gate.ts +16 -0
  86. package/src/plan-format/index.ts +23 -1
  87. package/src/plan-format/lanes.ts +35 -1
  88. package/src/plan-format/model-column.ts +52 -0
  89. package/src/plan-format/phases.ts +16 -1
  90. package/src/plan-format/plan-structure.ts +10 -28
  91. package/src/plan-format/progress.ts +7 -29
  92. package/src/plans/command.ts +3 -3
  93. package/src/plans/current-phase.ts +71 -0
  94. package/src/plans/ensure-panes.ts +2 -2
  95. package/src/plans/herd-command.ts +4 -0
  96. package/src/plans/lane-discovery.ts +14 -2
  97. package/src/plans/lane-tickets.ts +69 -0
  98. package/src/plans/layout.ts +182 -88
  99. package/src/plans/live.ts +5 -1
  100. package/src/plans/phase-models-read.ts +37 -0
  101. package/src/plans/phase-models.ts +70 -0
  102. package/src/plans/plan-panes.ts +8 -7
  103. package/src/plans/pull-requests.ts +78 -4
  104. package/src/plans/selection.ts +1 -40
  105. package/src/plans/settings.ts +9 -3
  106. package/src/plans/shown-with-more.ts +5 -0
  107. package/src/plans/snapshot.ts +82 -8
  108. package/src/plans/sprint-html.ts +2 -2
  109. package/src/plans/supervisor.ts +5 -4
  110. package/src/plans/symbols.ts +9 -6
  111. package/src/plans/texts.ts +30 -13
  112. package/src/plans/view.ts +1 -0
  113. package/src/sync/herdr-plugin.ts +1 -1
  114. package/src/sync/next-steps.ts +1 -1
  115. package/src/sync/render.ts +2 -1
  116. package/src/sync/setup-questions.ts +2 -2
  117. package/src/terminal/text.ts +4 -0
  118. package/src/terminal/types.ts +1 -1
  119. package/src/vocabulary/index.ts +39 -0
  120. package/src/watch/approvals.ts +96 -0
  121. package/src/watch/command.ts +154 -50
  122. package/src/watch/failed-tests.ts +52 -0
  123. package/src/watch/failure-read.ts +56 -0
  124. package/src/watch/failures.ts +107 -0
  125. package/src/watch/feedback-item.ts +21 -0
  126. package/src/watch/feedback-read.ts +2 -1
  127. package/src/watch/feedback.ts +37 -28
  128. package/src/watch/gh-process.ts +53 -0
  129. package/src/watch/gh.ts +10 -20
  130. package/src/watch/heartbeat-progress.ts +1 -1
  131. package/src/watch/lane-model.ts +54 -0
  132. package/src/watch/lane-parallel.ts +46 -0
  133. package/src/watch/lane-progress.ts +28 -4
  134. package/src/watch/lane-report.ts +62 -9
  135. package/src/watch/lane-sources.ts +1 -1
  136. package/src/watch/no-finding.ts +47 -0
  137. package/src/watch/plan-badges-read.ts +19 -7
  138. package/src/watch/plan-badges.ts +59 -7
  139. package/src/watch/plan-views.ts +5 -5
  140. package/src/watch/previews.ts +3 -9
  141. package/src/watch/pull-requests.ts +12 -2
  142. package/src/watch/recorded-reads.ts +25 -0
  143. package/src/watch/source-fields.ts +4 -0
  144. package/src/watch/source-tickets.ts +99 -0
  145. package/src/watch/sources.ts +62 -15
  146. package/src/watch/texts.ts +21 -1
  147. package/src/watch/watch-defaults.ts +8 -0
  148. package/src/watch/watch-events.ts +64 -4
  149. package/src/watch/watcher-lock.ts +1 -1
  150. package/src/watch/workflow-runs.ts +5 -7
  151. package/docs/lanes.mmd +0 -9
  152. package/docs/skills.mmd +0 -10
@@ -2,15 +2,21 @@ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import {
4
4
  boardRowsWithoutPhase,
5
+ boardRowsWithProse,
5
6
  hasLanesTable,
6
7
  type MergedPhaseWithOpenBoxes,
7
8
  mergedPhasesWithOpenBoxes,
8
9
  newPhasesWithoutReviewGate,
10
+ PHASE_CELL_LIMIT,
9
11
  type PhaseWithoutGate,
12
+ type PhaseWithoutRow,
10
13
  PLAN_VOCABULARY,
11
14
  type PlanLanguage,
15
+ PROSE_REASON,
12
16
  phaseIdsOfPlan,
17
+ phasesWithoutDependencyRow,
13
18
  type RowWithoutPhase,
19
+ type RowWithProse,
14
20
  } from '../plan-format'
15
21
  import { changedPlansOf } from './deferral-arrows'
16
22
  import { CHECK_STATUS, type CheckResult } from './result'
@@ -31,60 +37,97 @@ export type PlanCheckInput = {
31
37
  baseOf: ContentAt
32
38
  }
33
39
 
34
- type Changed = { path: string; current: string; previous: string | null; base: string | null; board: boolean }
40
+ type Changed = {
41
+ path: string
42
+ current: string
43
+ previous: string | null
44
+ previousLines: ReadonlySet<string>
45
+ base: string | null
46
+ board: boolean
47
+ }
35
48
 
36
49
  type Found<T> = T & { path: string }
37
50
 
38
- const gateResult = (
39
- findings: readonly Found<PhaseWithoutGate>[],
40
- plans: number,
41
- language: PlanLanguage,
51
+ type Report<T> = {
52
+ name: string
53
+ noun: 'plan' | 'board'
54
+ /** What the check asks of each changed file, as the command column reads after the count. */
55
+ rule: string
56
+ /** The status of a check with findings. */
57
+ unmet: typeof CHECK_STATUS.fail | typeof CHECK_STATUS.warn
58
+ text: (found: Found<T>) => string
59
+ clean: string
60
+ }
61
+
62
+ const changedCount = (count: number, noun: string): string => `${count} changed ${count === 1 ? noun : `${noun}s`}`
63
+
64
+ const findingsResult = <T>(
65
+ { name, noun, rule, unmet, text, clean }: Report<T>,
66
+ findings: readonly Found<T>[],
67
+ files: number,
42
68
  durationMs: number,
43
69
  ): CheckResult => ({
44
- name: 'Plan review gates',
45
- command: `${plans} changed ${plans === 1 ? 'plan' : 'plans'}, new phases need a review gate`,
46
- status: findings.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.fail,
47
- detail:
48
- findings
49
- .map(
50
- ({ path, line, id, title }) =>
51
- `${path}:${line} Phase ${id} ${title} has tasks but no review gate; add one, or a **${PLAN_VOCABULARY[language].noReviewGate}:** line with the reason`,
52
- )
53
- .join('\n') || 'every new phase has a review gate',
70
+ name,
71
+ command: `${changedCount(files, noun)}, ${rule}`,
72
+ status: findings.length === 0 ? CHECK_STATUS.pass : unmet,
73
+ detail: findings.map(text).join('\n') || clean,
54
74
  durationMs,
55
75
  })
56
76
 
57
- const rowResult = (findings: readonly Found<RowWithoutPhase>[], boards: number, durationMs: number): CheckResult => ({
58
- name: 'Board rows without a phase',
59
- command: `${boards} changed ${boards === 1 ? 'board' : 'boards'}, changed lane rows name a phase`,
60
- status: findings.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.warn,
61
- detail:
62
- findings
63
- .map(
64
- ({ path, line, lane, phases }) =>
65
- `${path}:${line} lane ${lane}: "${phases}" names no phase (P<n>), so the view cannot tie the step to one`,
66
- )
67
- .join('\n') || 'every changed lane row names a phase',
68
- durationMs,
77
+ const gateReport = (language: PlanLanguage): Report<PhaseWithoutGate> => ({
78
+ name: 'Plan review gates',
79
+ noun: 'plan',
80
+ rule: 'new phases need a review gate',
81
+ unmet: CHECK_STATUS.fail,
82
+ text: ({ path, line, id, title }) =>
83
+ `${path}:${line} Phase ${id} ${title} has tasks but no review gate; add one, or a **${PLAN_VOCABULARY[language].noReviewGate}:** line with the reason`,
84
+ clean: 'every new phase has a review gate',
69
85
  })
70
86
 
71
- const mergedResult = (
72
- findings: readonly Found<MergedPhaseWithOpenBoxes>[],
73
- plans: number,
74
- durationMs: number,
75
- ): CheckResult => ({
87
+ const rowReport: Report<RowWithoutPhase> = {
88
+ name: 'Board rows without a phase',
89
+ noun: 'board',
90
+ rule: 'changed lane rows name a phase',
91
+ unmet: CHECK_STATUS.warn,
92
+ text: ({ path, line, lane, phases }) =>
93
+ `${path}:${line} lane ${lane}: "${phases}" names no phase (P<n>), so the herd cannot tie the step to one`,
94
+ clean: 'every changed lane row names a phase',
95
+ }
96
+
97
+ const proseText = ({ reason, length }: RowWithProse): string =>
98
+ reason === PROSE_REASON.sideOrder
99
+ ? 'the phase cell records a side order of the user'
100
+ : `the phase cell has ${length} characters (limit ${PHASE_CELL_LIMIT})`
101
+
102
+ const proseReport: Report<RowWithProse> = {
103
+ name: 'Board rows with prose',
104
+ noun: 'board',
105
+ rule: 'changed lane rows name the phase only',
106
+ unmet: CHECK_STATUS.warn,
107
+ text: (found) =>
108
+ `${found.path}:${found.line} lane ${found.lane}: ${proseText(found)}; it belongs as a task in the plan`,
109
+ clean: 'every changed lane row names the phase only',
110
+ }
111
+
112
+ const mergedReport: Report<MergedPhaseWithOpenBoxes> = {
76
113
  name: 'Merged phases with open boxes',
77
- command: `${plans} changed ${plans === 1 ? 'plan' : 'plans'}, a merged phase ticks or moves on its open boxes`,
78
- status: findings.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.warn,
79
- detail:
80
- findings
81
- .map(
82
- ({ path, line, id, title, openLines }) =>
83
- `${path}:${line} Phase ${id} ${title} is merged but has open boxes at ${openLines.join(', ')}; tick them or end them with → P<n>`,
84
- )
85
- .join('\n') || 'no merged phase has an open box left',
86
- durationMs,
87
- })
114
+ noun: 'plan',
115
+ rule: 'a merged phase ticks or moves on its open boxes',
116
+ unmet: CHECK_STATUS.warn,
117
+ text: ({ path, line, id, title, openLines }) =>
118
+ `${path}:${line} Phase ${id} ${title} is merged but has open boxes at ${openLines.join(', ')}; tick them or end them with → P<n>`,
119
+ clean: 'no merged phase has an open box left',
120
+ }
121
+
122
+ const unlistedReport: Report<PhaseWithoutRow> = {
123
+ name: 'Phases without a dependency row',
124
+ noun: 'plan',
125
+ rule: 'every phase has a row in the dependency table',
126
+ unmet: CHECK_STATUS.warn,
127
+ text: ({ path, line, id, title }) =>
128
+ `${path}:${line} Phase ${id} ${title} has no row in the dependency table; add one with its dependencies, writes and serial marker`,
129
+ clean: 'every phase has a row in the dependency table',
130
+ }
88
131
 
89
132
  const withPath =
90
133
  <T>(path: string) =>
@@ -95,25 +138,38 @@ const gatesOf = ({ path, current, previous }: Changed, language: PlanLanguage):
95
138
  withPath(path),
96
139
  )
97
140
 
98
- const rowsOf = ({ path, current, previous }: Changed, language: PlanLanguage): Found<RowWithoutPhase>[] =>
99
- boardRowsWithoutPhase(current, language, new Set(previous?.split('\n') ?? [])).map(withPath(path))
141
+ const rowsOf = ({ path, current, previousLines }: Changed, language: PlanLanguage): Found<RowWithoutPhase>[] =>
142
+ boardRowsWithoutPhase(current, language, previousLines).map(withPath(path))
143
+
144
+ const proseOf = ({ path, current, previousLines }: Changed, language: PlanLanguage): Found<RowWithProse>[] =>
145
+ boardRowsWithProse(current, language, previousLines).map(withPath(path))
100
146
 
101
147
  const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<MergedPhaseWithOpenBoxes>[] =>
102
148
  base ? mergedPhasesWithOpenBoxes(current, base, language).map(withPath(path)) : []
103
149
 
150
+ const unlistedOf = ({ path, current }: Changed, language: PlanLanguage): Found<PhaseWithoutRow>[] | null =>
151
+ phasesWithoutDependencyRow(current, language)?.map(withPath(path)) ?? null
152
+
104
153
  const changedContents = ({ root, previousOf, baseOf, language }: PlanCheckInput, plans: readonly string[]): Changed[] =>
105
154
  plans
106
155
  .filter((path) => existsSync(join(root, path)))
107
156
  .map((path) => {
108
157
  const current = readFileSync(join(root, path), 'utf8')
109
- return { path, current, previous: previousOf(path), base: baseOf(path), board: hasLanesTable(current, language) }
158
+ const previous = previousOf(path)
159
+ const previousLines = new Set(previous?.split('\n') ?? [])
160
+ return { path, current, previous, previousLines, base: baseOf(path), board: hasLanesTable(current, language) }
110
161
  })
111
162
 
112
163
  /**
113
- * The structure of the changed plans and boards: a phase the change added (against `HEAD`) with tasks and without
114
- * a review gate fails; a lane row the change added or edited that names no phase warns; a phase whose tasks are
115
- * all ticked on the base branch and that still has open boxes without `→ P<n>` warns. Empty when no plan changed;
116
- * the row result is left out when no changed file has a lanes table, the merged result when every one has.
164
+ * The structure of the changed plans and boards:
165
+ * - a phase the change added (against `HEAD`) with tasks and without a review gate fails;
166
+ * - a lane row the change added or edited that names no phase warns, and so does one whose phases cell is longer
167
+ * than `PHASE_CELL_LIMIT` or records a side order of the user;
168
+ * - a phase whose tasks are all ticked on the base branch and that still has open boxes without `→ P<n>` warns;
169
+ * - a phase of a plan with a dependency table that has no row in it warns.
170
+ *
171
+ * Empty when no plan changed. The row and prose results are left out when no changed file has a lanes table, the
172
+ * merged result when every one has, the dependency result when no changed plan has the table.
117
173
  */
118
174
  export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
119
175
  const files = changedPlansOf(input.paths, input.planDirectory)
@@ -123,13 +179,24 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
123
179
  const changed = changedContents(input, files)
124
180
  const boards = changed.filter(({ board }) => board)
125
181
  const plans = changed.filter(({ board }) => !board)
182
+ const unlistedPerPlan = plans.map((file) => unlistedOf(file, language)).filter((found) => found !== null)
126
183
  const gates = changed.flatMap((file) => gatesOf(file, language))
127
184
  const rows = boards.flatMap((file) => rowsOf(file, language))
185
+ const prose = boards.flatMap((file) => proseOf(file, language))
128
186
  const merged = plans.flatMap((file) => mergedOf(file, language))
187
+ const unlisted = unlistedPerPlan.flat()
129
188
  const durationMs = Date.now() - started
130
189
  return [
131
- gateResult(gates, files.length, language, durationMs),
132
- ...(boards.length > 0 ? [rowResult(rows, boards.length, durationMs)] : []),
133
- ...(plans.length > 0 ? [mergedResult(merged, plans.length, durationMs)] : []),
190
+ findingsResult(gateReport(language), gates, files.length, durationMs),
191
+ ...(boards.length > 0
192
+ ? [
193
+ findingsResult(rowReport, rows, boards.length, durationMs),
194
+ findingsResult(proseReport, prose, boards.length, durationMs),
195
+ ]
196
+ : []),
197
+ ...(plans.length > 0 ? [findingsResult(mergedReport, merged, plans.length, durationMs)] : []),
198
+ ...(unlistedPerPlan.length > 0
199
+ ? [findingsResult(unlistedReport, unlisted, unlistedPerPlan.length, durationMs)]
200
+ : []),
134
201
  ]
135
202
  }
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { pathToFileURL } from 'node:url'
3
+ import { HERD_COMMAND } from './plans/herd-command'
3
4
  import { locateKit } from './sync/kit-source'
4
5
  import { CLI_NAME } from './version/release'
5
6
 
@@ -12,13 +13,17 @@ export type Resolution =
12
13
  | { kind: 'version' }
13
14
  | { kind: 'usage'; exitCode: number; message: string }
14
15
 
16
+ /** Commands that stay callable but are not advertised in the usage line. */
17
+ const ALIASES = new Set<string>([HERD_COMMAND.alias])
18
+
15
19
  /** One line per command; each entry lazily imports the module that exports its `run`. */
16
20
  export const COMMANDS: CommandTable = {
17
21
  check: () => import('./check/command'),
18
22
  config: () => import('./config/command'),
19
23
  context: () => import('./context/command'),
24
+ [HERD_COMMAND.name]: () => import('./plans/command'),
20
25
  init: () => import('./sync/init-command'),
21
- plans: () => import('./plans/command'),
26
+ [HERD_COMMAND.alias]: () => import('./plans/command'),
22
27
  sync: () => import('./sync/command'),
23
28
  update: () => import('./update/command'),
24
29
  watch: () => import('./watch/command'),
@@ -27,7 +32,8 @@ export const COMMANDS: CommandTable = {
27
32
  export const resolveCommand = (argv: string[], table: CommandTable): Resolution => {
28
33
  const [name, ...args] = argv
29
34
  const names = Object.keys(table)
30
- const usage = `Usage: ${CLI_NAME} <command> [args]\n ${CLI_NAME} --version\nCommands: ${names.length > 0 ? names.join(', ') : '(none yet)'}`
35
+ const advertised = names.filter((entry) => !ALIASES.has(entry))
36
+ const usage = `Usage: ${CLI_NAME} <command> [args]\n ${CLI_NAME} --version\nCommands: ${advertised.length > 0 ? advertised.join(', ') : '(none yet)'}`
31
37
  if (name === '--version' || name === '-v') return { kind: 'version' }
32
38
  if (!name || name === '--help' || name === '-h') return { kind: 'usage', exitCode: name ? 0 : 1, message: usage }
33
39
  if (!names.includes(name)) return { kind: 'usage', exitCode: 1, message: `Unknown command: ${name}\n${usage}` }
@@ -0,0 +1,73 @@
1
+ type Group = { repeats: boolean; branches: string[] }
2
+
3
+ const GROUP_PREFIX = /^\?(?::|=|!|<=|<!|<[^>]+>)/
4
+ const BRACES = /^\{(\d+)(?:(,)(\d*))?\}/
5
+
6
+ /** Length of an unbounded or repeating quantifier (`*`, `+`, `{n,}`, `{n,m}` with m above 1) at `index`, else 0. */
7
+ const repeatingLengthAt = (pattern: string, index: number): number => {
8
+ if (pattern[index] === '*' || pattern[index] === '+') return 1
9
+ const braces = pattern.slice(index).match(BRACES)
10
+ const [text, , comma, max] = braces ?? []
11
+ return text !== undefined && comma && (max === '' || Number(max) > 1) ? text.length : 0
12
+ }
13
+
14
+ const classEnd = (pattern: string, start: number): number => {
15
+ let index = start + 1
16
+ while (index < pattern.length && pattern[index] !== ']') index += pattern[index] === '\\' ? 2 : 1
17
+ return index
18
+ }
19
+
20
+ const overlapsAnother = (branches: readonly string[]): boolean =>
21
+ branches.some((branch, index) =>
22
+ branches.some((other, otherIndex) => index !== otherIndex && other.startsWith(branch)),
23
+ )
24
+
25
+ const NESTED = -1
26
+
27
+ const appendTo = (group: Group, text: string): void => {
28
+ group.branches[group.branches.length - 1] += text
29
+ }
30
+
31
+ const prefixLengthAt = (pattern: string, index: number): number =>
32
+ pattern.slice(index + 1).match(GROUP_PREFIX)?.[0].length ?? 0
33
+
34
+ /** Closes the group at `index`; NESTED when it repeats and holds a repeat or branches that begin alike. */
35
+ const closeGroup = (pattern: string, index: number, groups: Group[]): number => {
36
+ const closed = groups.pop() as Group
37
+ const repeated = repeatingLengthAt(pattern, index + 1) > 0
38
+ if (repeated && (closed.repeats || overlapsAnother(closed.branches))) return NESTED
39
+ groups[groups.length - 1].repeats ||= closed.repeats
40
+ return index + 1
41
+ }
42
+
43
+ /** Reads the token at `index` into the open groups and returns the index after it, or NESTED. */
44
+ const stepAt = (pattern: string, index: number, groups: Group[]): number => {
45
+ const top = groups[groups.length - 1]
46
+ const char = pattern[index]
47
+ const literalEnd = char === '\\' ? index + 2 : classEnd(pattern, index) + 1
48
+ const quantifierLength = repeatingLengthAt(pattern, index)
49
+ if (char === '\\' || char === '[') appendTo(top, pattern.slice(index, literalEnd))
50
+ else if (char === '(') groups.push({ repeats: false, branches: [''] })
51
+ else if (char === ')' && groups.length > 1) return closeGroup(pattern, index, groups)
52
+ else if (char === '|') top.branches.push('')
53
+ else if (quantifierLength > 0) top.repeats = true
54
+ else appendTo(top, char)
55
+ if (char === '\\' || char === '[') return literalEnd
56
+ if (char === '(') return index + 1 + prefixLengthAt(pattern, index)
57
+ return index + Math.max(quantifierLength, 1)
58
+ }
59
+
60
+ /**
61
+ * Whether a regular expression repeats a group that repeats inside itself, the shape of catastrophic backtracking:
62
+ * `(a+)+`, `(\w*)*`, `(x+y)+z`, and with alternation whose branches begin alike, `(a|aa)+`. A best-effort scan of the
63
+ * source, not a proof: it misses overlaps it cannot see in the text (`(a|a?)+`), and it flags a few harmless ones.
64
+ */
65
+ export const hasNestedQuantifier = (pattern: string): boolean => {
66
+ const groups: Group[] = [{ repeats: false, branches: [''] }]
67
+ let index = 0
68
+ while (index < pattern.length) {
69
+ index = stepAt(pattern, index, groups)
70
+ if (index === NESTED) return true
71
+ }
72
+ return false
73
+ }
@@ -0,0 +1,11 @@
1
+ import { isAbsolute } from 'node:path'
2
+
3
+ const segmentsOf = (path: string): string[] =>
4
+ path.split(/[\\/]/).filter((segment) => segment !== '' && segment !== '.')
5
+
6
+ /**
7
+ * A directory below the repository root: relative, not the root itself and without a `..` segment. The approval gate
8
+ * hook applies the same rule (`kit/hooks/hooks/approval-gate/settings.ts`), which this package does not import.
9
+ */
10
+ export const isRepositoryDirectory = (value: string): boolean =>
11
+ !isAbsolute(value) && segmentsOf(value).length > 0 && !segmentsOf(value).includes('..')
@@ -2,10 +2,13 @@ import { z } from 'zod'
2
2
  import { checkConfigSchema } from '../check/schema'
3
3
  import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
4
4
  import { PLAN_LANGUAGE, RESPONSE_ADDRESS } from '../vocabulary'
5
- import { SOURCE_FIELD } from '../watch/source-fields'
5
+ import { CONTENT_FIELDS, SOURCE_FIELD } from '../watch/source-fields'
6
6
  import { isSourcePath } from '../watch/source-path'
7
+ import { BUN_FAILED_TEST_PATTERN, CLEAN_REVIEW_TEXT } from '../watch/watch-defaults'
7
8
  import { BUN_COMMANDS, DEFAULT_BASE_BRANCH } from './detect'
8
9
  import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
10
+ import { hasNestedQuantifier } from './nested-quantifier'
11
+ import { isRepositoryDirectory } from './repository-directory'
9
12
 
10
13
  export const TERMINAL_ADAPTER = { none: 'none', herdr: 'herdr' } as const
11
14
 
@@ -89,7 +92,7 @@ const boardSchema = z.strictObject({
89
92
  pattern: boardPattern.default('board-*'),
90
93
  /** The board document inside each of those directories. */
91
94
  file: boardFile.default('board.md'),
92
- /** What the project calls a board: the plans view, its page and the watcher print it as written. */
95
+ /** What the project calls a board: the herd, its page and the watcher print it as written. */
93
96
  term: z.string().min(1).default('board'),
94
97
  })
95
98
 
@@ -103,6 +106,28 @@ const deploySchema = z.strictObject({
103
106
  const feedbackSchema = z.strictObject({
104
107
  /** GitHub logins whose feedback does not wake the coordinator, such as a status bot; case is ignored. */
105
108
  ignore: z.array(z.string().min(1)).default([]),
109
+ /**
110
+ * Texts of a bot's pull request comment that report no finding: such a comment (login ending `[bot]`, no inline
111
+ * comment by the same author within five minutes of it) is no feedback. Compared with whitespace collapsed; the
112
+ * default is the CI code review's comment for a clean run. An empty list counts every bot comment.
113
+ */
114
+ noFindingTemplates: z.array(z.string().min(1)).default([CLEAN_REVIEW_TEXT]),
115
+ })
116
+
117
+ const checksSchema = z.strictObject({
118
+ /**
119
+ * Finds a failed test in a line of a red run's log (`gh run view --log-failed`) after its job, step and timestamp
120
+ * prefix: the first group is the name, else the match. The default is Bun's `(fail) <name> [<duration>]`. The
121
+ * watcher runs it on every line of a log it does not control, so a pattern that repeats a group which repeats inside
122
+ * itself (`(a+)+`, `(\w*)*`, and with alternation `(a|aa)+`) is refused: a best-effort scan of the source against
123
+ * catastrophic backtracking, not a proof.
124
+ */
125
+ failedTestPattern: z
126
+ .string()
127
+ .min(1)
128
+ .refine(isRegularExpression, 'not a valid regular expression')
129
+ .refine((pattern) => !hasNestedQuantifier(pattern), 'a quantified group that holds a quantifier backtracks badly')
130
+ .default(BUN_FAILED_TEST_PATTERN),
106
131
  })
107
132
 
108
133
  const previewsSchema = z.strictObject({
@@ -121,24 +146,33 @@ const sourcePath = z.string().refine(isSourcePath, 'not a source path: `.`, or `
121
146
 
122
147
  const SOURCE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
123
148
 
124
- /** The six known fields, and any further field (`status`, `priority`, …) whose name is a plain name, for `reportOn`. */
149
+ /** The seven known fields, and any further field (`priority`, `assignee`, …) whose name is a plain name, for `reportOn`. */
125
150
  const sourceMapSchema = z
126
151
  .object({
127
152
  [SOURCE_FIELD.id]: sourcePath,
128
- /** Changes whenever the entry changes; compared as text, so any stable form of a time or version works. */
129
- [SOURCE_FIELD.updatedAt]: sourcePath,
153
+ /**
154
+ * Changes whenever the entry changes; compared as text, so any stable form of a time or version works. Needed
155
+ * only where no content field and no `reportOn` can tell a change.
156
+ */
157
+ [SOURCE_FIELD.updatedAt]: sourcePath.optional(),
130
158
  [SOURCE_FIELD.title]: sourcePath.optional(),
131
159
  /** Shown only as an `https:` URL without user name or password. */
132
160
  [SOURCE_FIELD.url]: sourcePath.optional(),
133
161
  /** The first line with text is shown. */
134
162
  [SOURCE_FIELD.body]: sourcePath.optional(),
135
163
  [SOURCE_FIELD.author]: sourcePath.optional(),
164
+ /** A ticket source's status, shown on its badge in the herd; also a field `reportOn` can name. */
165
+ [SOURCE_FIELD.status]: sourcePath.optional(),
136
166
  })
137
167
  .catchall(sourcePath)
138
168
  .refine((map) => Object.keys(map).every((field) => SOURCE_FIELD_NAME.test(field)), {
139
169
  message: 'a map field is a letter, then letters, digits and `_`',
140
170
  })
141
171
 
172
+ /** Whether an entry of the source can change at all: by `updatedAt`, a content field or a `reportOn` field. */
173
+ const canChange = ({ map, reportOn }: { map: Record<string, string | undefined>; reportOn?: string[] }): boolean =>
174
+ map.updatedAt !== undefined || CONTENT_FIELDS.some((field) => map[field] !== undefined) || reportOn !== undefined
175
+
142
176
  const sourceSchema = z
143
177
  .strictObject({
144
178
  /** Printed in every report of the source and the key of its saved state. */
@@ -156,12 +190,21 @@ const sourceSchema = z
156
190
  reportLimit: z.number().int().min(1).default(5),
157
191
  /** The lane the source's reports name. */
158
192
  lane: z.string().min(1).optional(),
193
+ /**
194
+ * Marks the entries as tickets: the herd shows each as a badge beside the plan of the lane it belongs to,
195
+ * the lane above or the lane whose board row or plan names its id.
196
+ */
197
+ ticket: z.boolean().optional(),
159
198
  /**
160
199
  * The mapped fields whose change reports, with the old and the new value, in place of the content of `title`,
161
200
  * `author`, `body` and `url`; without it a change of that content reports.
162
201
  */
163
202
  reportOn: z.array(z.string().min(1)).min(1).max(10).optional(),
164
203
  })
204
+ .refine(canChange, {
205
+ message: 'map needs updatedAt, a content field (title, author, body, url) or reportOn',
206
+ path: ['map'],
207
+ })
165
208
  .refine((source) => source.ignoreAuthors.length === 0 || source.map.author !== undefined, {
166
209
  message: 'ignoreAuthors needs map.author',
167
210
  path: ['ignoreAuthors'],
@@ -232,6 +275,12 @@ const guardSchema = z.strictObject({
232
275
  approvalMinutes: z.number().int().min(1).max(1440).default(180),
233
276
  })
234
277
  .prefault({}),
278
+ approvals: z
279
+ .strictObject({
280
+ /** How long the approval gate hook holds a lane's call for the coordinator's dialog before it denies it; below the hook's 10 minutes. */
281
+ waitMinutes: z.number().int().min(1).max(9).default(8),
282
+ })
283
+ .prefault({}),
235
284
  })
236
285
 
237
286
  const hintsSchema = z.strictObject({
@@ -267,7 +316,11 @@ export const jackherdConfigSchema = z.strictObject({
267
316
  sprintPattern: z
268
317
  .never({ error: 'sprintPattern was replaced by board.pattern (with board.file and board.term)' })
269
318
  .optional(),
270
- worktreeDirectory: z.string().min(1).default(claudePath(WORKTREES_DIRECTORY)),
319
+ worktreeDirectory: z
320
+ .string()
321
+ .min(1)
322
+ .refine(isRepositoryDirectory, 'must lie below the repository root: relative, not `.`, without `..`')
323
+ .default(claudePath(WORKTREES_DIRECTORY)),
271
324
  baseBranch: branchName.default(DEFAULT_BASE_BRANCH),
272
325
  /**
273
326
  * Put before every skill and agent name. `jh sync --global` refuses to run without one: a personal skill
@@ -304,7 +357,7 @@ export const jackherdConfigSchema = z.strictObject({
304
357
  .prefault({}),
305
358
  plans: z
306
359
  .strictObject({
307
- /** Minutes between background fetches of the base branch while `jh plans` runs; 0 turns them off. */
360
+ /** Minutes between background fetches of the base branch while `jh herd` runs; 0 turns them off. */
308
361
  fetchIntervalMinutes: z.number().min(0).default(5),
309
362
  /** Links in a plan that the sprint page shows as chips; the list replaces the built-in kinds. */
310
363
  linkKinds: z
@@ -322,13 +375,15 @@ export const jackherdConfigSchema = z.strictObject({
322
375
  /**
323
376
  * Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
324
377
  * it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
378
+ * `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
325
379
  * Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
326
380
  * command whose JSON output reports `source` for a new or changed entry.
327
381
  */
328
382
  watch: z
329
383
  .strictObject({
330
384
  deploy: deploySchema.optional(),
331
- feedback: z.union([z.literal(false), feedbackSchema]).default({ ignore: [] }),
385
+ feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
386
+ checks: checksSchema.prefault({}),
332
387
  previews: previewsSchema.optional(),
333
388
  sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
334
389
  })
@@ -0,0 +1,36 @@
1
+ import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
+ import { changedOpenLaneRows } from './lanes'
3
+
4
+ /**
5
+ * The longest phases cell, in characters, that still reads as a phase and its state. Longer cells in the open
6
+ * lanes of the real boards carry prose, which belongs in the plan.
7
+ */
8
+ export const PHASE_CELL_LIMIT = 250
9
+
10
+ export const PROSE_REASON = { long: 'long', sideOrder: 'side-order' } as const
11
+ export type ProseReason = (typeof PROSE_REASON)[keyof typeof PROSE_REASON]
12
+
13
+ /** A changed row of an open lane (1-based line) whose phases cell is `length` characters and carries prose. */
14
+ export type RowWithProse = { line: number; lane: string; reason: ProseReason; length: number }
15
+
16
+ const reasonOf = (phases: string, marker: string): ProseReason | null => {
17
+ if (phases.includes(marker)) return PROSE_REASON.sideOrder
18
+ return phases.length > PHASE_CELL_LIMIT ? PROSE_REASON.long : null
19
+ }
20
+
21
+ /**
22
+ * The changed rows of open lanes whose phases cell is longer than `PHASE_CELL_LIMIT` or records a side order of the
23
+ * user in prose (`dazu (User …`): the board row names the phase, the side order is a task in the plan's phase.
24
+ * A cell that is both counts once, as a side order.
25
+ */
26
+ export const boardRowsWithProse = (
27
+ markdown: string,
28
+ language: PlanLanguage,
29
+ previousLines: ReadonlySet<string>,
30
+ ): RowWithProse[] => {
31
+ const { sideOrderMarker } = PLAN_VOCABULARY[language]
32
+ return changedOpenLaneRows(markdown, language, previousLines).flatMap(({ line, lane, phases }) => {
33
+ const reason = reasonOf(phases, sideOrderMarker)
34
+ return reason === null ? [] : [{ line, lane, reason, length: phases.length }]
35
+ })
36
+ }