jackherd 1.10.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 (64) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +17 -2
  3. package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
  4. package/dist/config.d.ts +60 -0
  5. package/dist/config.js +39 -4
  6. package/dist/plan-format.d.ts +7 -1
  7. package/dist/plan-format.js +4 -2
  8. package/herdr-plugin/herdr-plugin.toml +1 -1
  9. package/install.sh +1 -1
  10. package/kit/hooks/hooks/open-question/run.ts +8 -3
  11. package/kit/rules/rules/kit/pull-requests.md +2 -0
  12. package/kit/workflow/skills/follow/SKILL.md +3 -3
  13. package/kit/workflow/skills/orchestrate/SKILL.md +10 -2
  14. package/package.json +1 -1
  15. package/recipes/sources.md +4 -0
  16. package/schemas/jackherd.schema.json +80 -0
  17. package/src/cli.ts +1 -0
  18. package/src/config/schema.ts +24 -1
  19. package/src/config/tones.ts +27 -0
  20. package/src/lane-prompt/additions.ts +14 -0
  21. package/src/lane-prompt/command.ts +8 -2
  22. package/src/lane-prompt/input.ts +44 -3
  23. package/src/lane-prompt/options.ts +2 -0
  24. package/src/mermaid/command.ts +36 -0
  25. package/src/mermaid/problems.ts +48 -0
  26. package/src/plan-format/dependencies.ts +2 -2
  27. package/src/plan-format/waiting.ts +15 -2
  28. package/src/plans/appointment-rows.ts +34 -10
  29. package/src/plans/ask-coordinator.ts +46 -12
  30. package/src/plans/ask-target.ts +13 -2
  31. package/src/plans/board-close.ts +85 -0
  32. package/src/plans/frame.ts +5 -12
  33. package/src/plans/git.ts +35 -0
  34. package/src/plans/groomed.ts +27 -0
  35. package/src/plans/layout-phase.ts +7 -4
  36. package/src/plans/layout.ts +258 -48
  37. package/src/plans/link-chips.ts +22 -4
  38. package/src/plans/live.ts +73 -5
  39. package/src/plans/next-rows.ts +44 -0
  40. package/src/plans/planned.ts +22 -0
  41. package/src/plans/row-actions.ts +52 -0
  42. package/src/plans/row-focus.ts +148 -0
  43. package/src/plans/row-targets.ts +7 -0
  44. package/src/plans/selection.ts +14 -1
  45. package/src/plans/settings.ts +10 -0
  46. package/src/plans/snapshot.ts +79 -21
  47. package/src/plans/source-asked.ts +19 -0
  48. package/src/plans/source-prefix.ts +17 -0
  49. package/src/plans/source-rows.ts +188 -0
  50. package/src/plans/texts.ts +28 -3
  51. package/src/plans/tones.ts +30 -0
  52. package/src/plans/view.ts +8 -2
  53. package/src/plans/waiting-click.ts +129 -24
  54. package/src/terminal/text.ts +8 -0
  55. package/src/terminal/types.ts +4 -0
  56. package/src/vocabulary/index.ts +10 -0
  57. package/src/watch/plan-badges-read.ts +9 -2
  58. package/src/watch/plan-badges.ts +10 -2
  59. package/src/watch/report-questions.ts +4 -3
  60. package/src/watch/source-entries.ts +55 -0
  61. package/src/watch/source-fields.ts +1 -0
  62. package/src/watch/source-statuses.ts +60 -0
  63. package/src/watch/source-tickets.ts +21 -1
  64. package/src/watch/sources.ts +41 -1
@@ -10,6 +10,7 @@ import { BUN_COMMANDS, DEFAULT_BASE_BRANCH } from './detect'
10
10
  import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
11
11
  import { hasNestedQuantifier } from './nested-quantifier'
12
12
  import { isRepositoryDirectory } from './repository-directory'
13
+ import { DEFAULT_TONES, TONE_NAMES } from './tones'
13
14
 
14
15
  export const TERMINAL_ADAPTER = { none: 'none', herdr: 'herdr' } as const
15
16
 
@@ -176,7 +177,7 @@ const sourcePath = z.string().refine(isSourcePath, 'not a source path: `.`, or `
176
177
 
177
178
  const SOURCE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
178
179
 
179
- /** The seven known fields, and any further field (`priority`, `assignee`, …) whose name is a plain name, for `reportOn`. */
180
+ /** The eight known fields, and any further field (`priority`, `assignee`, …) whose name is a plain name, for `reportOn`. */
180
181
  const sourceMapSchema = z
181
182
  .object({
182
183
  [SOURCE_FIELD.id]: sourcePath,
@@ -193,6 +194,8 @@ const sourceMapSchema = z
193
194
  [SOURCE_FIELD.author]: sourcePath.optional(),
194
195
  /** A ticket source's status, shown on its badge in the herd; also a field `reportOn` can name. */
195
196
  [SOURCE_FIELD.status]: sourcePath.optional(),
197
+ /** How often the entry happened (an error count), shown as `113×` beside the entry in the herd's `Sources` block. */
198
+ [SOURCE_FIELD.count]: sourcePath.optional(),
196
199
  })
197
200
  .catchall(sourcePath)
198
201
  .refine((map) => Object.keys(map).every((field) => SOURCE_FIELD_NAME.test(field)), {
@@ -203,6 +206,8 @@ const sourceMapSchema = z
203
206
  const canChange = ({ map, reportOn }: { map: Record<string, string | undefined>; reportOn?: string[] }): boolean =>
204
207
  map.updatedAt !== undefined || CONTENT_FIELDS.some((field) => map[field] !== undefined) || reportOn !== undefined
205
208
 
209
+ const toneName = z.enum(TONE_NAMES)
210
+
206
211
  const sourceSchema = z
207
212
  .strictObject({
208
213
  /** Printed in every report of the source and the key of its saved state. */
@@ -218,6 +223,13 @@ const sourceSchema = z
218
223
  ignoreAuthors: z.array(z.string().min(1)).default([]),
219
224
  /** The most new or changed entries one report shows of this source; the rest is one line `+<n> more`. */
220
225
  reportLimit: z.number().int().min(1).default(5),
226
+ /** Shown before a row of "Waiting for you" that came from this source (`Error log · Checkout: …`); without it the source's name. */
227
+ label: z
228
+ .string()
229
+ .min(1)
230
+ .max(40)
231
+ .regex(/^[^\r\n]+$/, 'one line')
232
+ .optional(),
221
233
  /** The lane the source's reports name. */
222
234
  lane: z.string().min(1).optional(),
223
235
  /**
@@ -225,6 +237,8 @@ const sourceSchema = z
225
237
  * the lane above or the lane whose board row or plan names its id.
226
238
  */
227
239
  ticket: z.boolean().optional(),
240
+ /** The tone of the source's mark in the herd's `Sources` block while its newest report is new; without it the tone of the role `new`. */
241
+ tone: toneName.optional(),
228
242
  /**
229
243
  * The mapped fields whose change reports, with the old and the new value, in place of the content of `title`,
230
244
  * `author`, `body` and `url`; without it a change of that content reports.
@@ -383,6 +397,13 @@ const hintsSchema = z.strictObject({
383
397
  /** Minutes a lane works without a task signal before the watcher reports `no task signal`. */
384
398
  const DEFAULT_TASK_SIGNAL_MINUTES = 10
385
399
 
400
+ const tonesSchema = z.strictObject({
401
+ waiting: toneName.default(DEFAULT_TONES.waiting),
402
+ new: toneName.default(DEFAULT_TONES.new),
403
+ running: toneName.default(DEFAULT_TONES.running),
404
+ resting: toneName.default(DEFAULT_TONES.resting),
405
+ })
406
+
386
407
  const contextSchema = z
387
408
  .strictObject({
388
409
  /** Tokens in a lane's context from which its fill shows yellow and `/implement` offers a new chat. */
@@ -475,6 +496,8 @@ export const jackherdConfigSchema = z.strictObject({
475
496
  )
476
497
  .default(DEFAULT_LINK_KINDS.map((kind) => ({ ...kind }))),
477
498
  context: contextSchema.prefault({}),
499
+ /** The colour of each role in the herd, by name: what waits for you, what is new, what runs, what rests. */
500
+ tones: tonesSchema.prefault({}),
478
501
  })
479
502
  .prefault({}),
480
503
  /**
@@ -0,0 +1,27 @@
1
+ /** The colours the herd paints with by name; the terminal's own palette decides how each looks, `grey` is the dimmed text colour. */
2
+ export const TONE = {
3
+ amber: 'amber',
4
+ teal: 'teal',
5
+ grey: 'grey',
6
+ red: 'red',
7
+ green: 'green',
8
+ blue: 'blue',
9
+ magenta: 'magenta',
10
+ } as const
11
+
12
+ export type ToneName = (typeof TONE)[keyof typeof TONE]
13
+
14
+ export const TONE_NAMES = Object.values(TONE)
15
+
16
+ /** What a tone is for in the herd: what waits for you, what is new, what runs, what rests. */
17
+ export const TONE_ROLE = { waiting: 'waiting', new: 'new', running: 'running', resting: 'resting' } as const
18
+
19
+ export type ToneRole = (typeof TONE_ROLE)[keyof typeof TONE_ROLE]
20
+
21
+ /** The tone of each role, as the herd looked before the tones were configurable. */
22
+ export const DEFAULT_TONES: Record<ToneRole, ToneName> = {
23
+ [TONE_ROLE.waiting]: TONE.amber,
24
+ [TONE_ROLE.new]: TONE.amber,
25
+ [TONE_ROLE.running]: TONE.teal,
26
+ [TONE_ROLE.resting]: TONE.grey,
27
+ }
@@ -0,0 +1,14 @@
1
+ /** The line that ends a written lane prompt; what the coordinator adds below it survives every new writing. */
2
+ export const ADDITIONS_MARKER = '<!-- jackherd: additions below this line stay when the prompt is written again -->'
3
+
4
+ /** What follows the marker line of a written prompt; empty for no file, no marker line or nothing below it. */
5
+ export const additionsOf = (existing: string | null): string => {
6
+ if (existing === null) return ''
7
+ const lines = existing.split('\n')
8
+ const marker = lines.indexOf(ADDITIONS_MARKER)
9
+ return marker === -1 ? '' : lines.slice(marker + 1).join('\n')
10
+ }
11
+
12
+ /** `prompt` closed by the marker and by the additions of the file it replaces, so writing twice changes nothing. */
13
+ export const withAdditions = (prompt: string, existing: string | null): string =>
14
+ `${prompt}\n${ADDITIONS_MARKER}\n${additionsOf(existing)}`
@@ -5,6 +5,7 @@ import { boardFor } from '../plans/board-pick'
5
5
  import { mainCheckout, worktreeRoot } from '../plans/git'
6
6
  import { boardRepoPathOf, readSettings, worktreeOf } from '../plans/settings'
7
7
  import { asksForHelp } from '../preview/options'
8
+ import { withAdditions } from './additions'
8
9
  import { buildLanePrompt } from './build'
9
10
  import { type BoardSource, type LanePromptRequest, lanePromptInputOf, type ReadPlan } from './input'
10
11
  import { LANE_PROMPT_USAGE, type LanePromptOptions, parseLanePromptOptions } from './options'
@@ -53,15 +54,19 @@ const promptSourcesOf = (options: LanePromptOptions, cwd: string): PromptSources
53
54
  }
54
55
  }
55
56
 
57
+ /** Writes `prompt` to `out`, closed by the marker and by the additions the file there already carries below it. */
56
58
  const writePrompt = (out: string, prompt: string): void => {
59
+ const existing = existsSync(out) ? readFileSync(out, 'utf8') : null
57
60
  mkdirSync(dirname(out), { recursive: true })
58
- writeFileSync(out, prompt)
61
+ writeFileSync(out, withAdditions(prompt, existing))
59
62
  }
60
63
 
61
64
  /**
62
65
  * `jh lane prompt <lane> [--board <board.md>] [--out <file>] [--handover <file>]`: writes the lane's prompt, built
63
66
  * from its board row and its plan in the plan language, and prints the file's path, so the coordinator sends it with
64
- * `herdr agent prompt <lane> "$(cat <file>)"`. `--help` prints the usage. Exit 0 written, 1 refused or failed.
67
+ * `herdr agent prompt <lane> "$(cat <file>)"`. Below the marker line at the file's end the coordinator adds what the
68
+ * plan does not say; it stays through every new writing. A phase two open rows name is in no prompt and is reported on
69
+ * stderr. `--help` prints the usage. Exit 0 written, 1 refused or failed.
65
70
  */
66
71
  export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
67
72
  if (asksForHelp(args)) {
@@ -76,6 +81,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
76
81
  console.error(result.reason)
77
82
  return 1
78
83
  }
84
+ for (const notice of result.notices ?? []) console.error(notice)
79
85
  const out = options.out === null ? defaultPromptFile(root, options.lane) : resolve(cwd, options.out)
80
86
  writePrompt(out, buildLanePrompt(result.input))
81
87
  console.log(out)
@@ -17,7 +17,10 @@ export type BoardSource = { text: string; directory: string; path: string }
17
17
  /** A plan's text by its path from the repository root; null when it cannot be read. */
18
18
  export type ReadPlan = (path: string) => string | null
19
19
 
20
- export type LanePromptResult = { kind: 'prompt'; input: LanePromptInput } | { kind: 'refused'; reason: string }
20
+ /** `notices` is absent unless the board needs fixing: a phase two open rows name is in neither prompt. */
21
+ export type LanePromptResult =
22
+ | { kind: 'prompt'; input: LanePromptInput; notices?: string[] }
23
+ | { kind: 'refused'; reason: string }
21
24
 
22
25
  type PlanSteps = { path: string; ids: string[] }
23
26
 
@@ -90,6 +93,37 @@ const plansOf = (
90
93
  return { plans }
91
94
  }
92
95
 
96
+ /** The other open lanes whose rows name the phase `id` of the plan at `path`, by name. */
97
+ const otherNamersOf = (others: readonly SprintLane[], directory: string, path: string, id: string): string[] =>
98
+ others
99
+ .filter((lane) => planStepsOf(lane, directory).some((steps) => steps.path === path && steps.ids.includes(id)))
100
+ .map((lane) => lane.name)
101
+
102
+ const withoutContested = (
103
+ plans: readonly LanePlan[],
104
+ lane: string,
105
+ others: readonly SprintLane[],
106
+ directory: string,
107
+ ): { plans: LanePlan[]; notices: string[] } => {
108
+ const judged = plans.map((plan) => ({
109
+ plan,
110
+ phases: plan.phases.map((phase) => ({ phase, namers: otherNamersOf(others, directory, plan.path, phase.id) })),
111
+ }))
112
+ const kept = judged.map(({ plan, phases }) => ({
113
+ ...plan,
114
+ phases: phases.filter(({ namers }) => namers.length === 0).map(({ phase }) => phase),
115
+ }))
116
+ const notices = judged.flatMap(({ plan, phases }) =>
117
+ phases
118
+ .filter(({ namers }) => namers.length > 0)
119
+ .map(
120
+ ({ phase, namers }) =>
121
+ `${plan.path} ${phaseLabel(phase.id)} is named by ${[lane, ...namers].sort().join(' and ')}, so it is in neither prompt; fix the board`,
122
+ ),
123
+ )
124
+ return { plans: kept.filter((plan) => plan.phases.length > 0), notices }
125
+ }
126
+
93
127
  /**
94
128
  * What the lane's prompt is built from: its open row of the board's lanes table (port, plans, phases), each named
95
129
  * phase that is not finished, and the write set of each from its plan's dependency table. A plan is read from the lane
@@ -103,7 +137,8 @@ export const lanePromptInputOf = (
103
137
  readPlan: ReadPlan,
104
138
  readLanePlan: ReadPlan = () => null,
105
139
  ): LanePromptResult => {
106
- const sprintLane = parseSprintLanes(board.text, request.language).find((candidate) => candidate.name === request.lane)
140
+ const lanes = parseSprintLanes(board.text, request.language)
141
+ const sprintLane = lanes.find((candidate) => candidate.name === request.lane)
107
142
  if (!sprintLane) return refused(`no row for lane ${request.lane} in ${board.path}`)
108
143
  if (sprintLane.closed) return refused(`lane ${request.lane} is closed in ${board.path}`)
109
144
  const steps = planStepsOf(sprintLane, board.directory)
@@ -112,5 +147,11 @@ export const lanePromptInputOf = (
112
147
  if ('reason' in found) return refused(found.reason)
113
148
  if (found.plans.length === 0) return refused(`every phase of lane ${request.lane} is finished`)
114
149
  const { lane, handover, language } = request
115
- return { kind: 'prompt', input: { language, lane, plans: found.plans, port: sprintLane.port, handover } }
150
+ const others = lanes.filter((candidate) => candidate.name !== lane && !candidate.closed)
151
+ const { plans, notices } = withoutContested(found.plans, lane, others, board.directory)
152
+ if (plans.length === 0) {
153
+ return refused(`every open phase of lane ${lane} is also named by another lane: ${notices.join('; ')}`)
154
+ }
155
+ const input = { language, lane, plans, port: sprintLane.port, handover }
156
+ return notices.length === 0 ? { kind: 'prompt', input } : { kind: 'prompt', input, notices }
116
157
  }
@@ -13,6 +13,8 @@ const SUBCOMMAND = 'prompt'
13
13
  export const LANE_PROMPT_USAGE = `usage: ${CLI_NAME} lane prompt <lane> [--board <board.md>] [--out <file>] [--handover <file>]
14
14
 
15
15
  Writes the prompt that starts the lane, from its row in the board and its plan, and prints the file's path.
16
+ What you add below the marker line at the end of the file stays when the prompt is written again.
17
+ A phase that two open rows of the board name is in neither prompt; the command reports it.
16
18
  --board <board.md> the board to read; without it the board is picked as for ${CLI_NAME} herd
17
19
  --out <file> where to write the prompt; default .claude/handover/<lane>-prompt.md in the main checkout
18
20
  --handover <file> a restart after a hand-over: the lane reads this file first and continues with its next step`
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { asksForHelp } from '../preview/options'
3
+ import { CLI_NAME } from '../version/release'
4
+ import { type MermaidProblem, mermaidProblems } from './problems'
5
+
6
+ const USAGE = `usage: ${CLI_NAME} mermaid <file.md>
7
+
8
+ Checks every \`\`\`mermaid block of the file for edge labels GitHub does not draw (a label between the dashes,
9
+ a pipe label without quotes). Run it on a pull request body before gh pr create or gh pr edit.
10
+ Exit 0 clean, 1 with problems or a wrong command line.`
11
+
12
+ /** One line per problem, `file:line: reason`, then the offending text indented. */
13
+ const problemLines = (file: string, problems: readonly MermaidProblem[]): string[] =>
14
+ problems.flatMap(({ line, reason, text }) => [`${file}:${line}: ${reason}`, ` ${text}`])
15
+
16
+ export const run = (args: string[]): number => {
17
+ if (asksForHelp(args)) {
18
+ console.log(USAGE)
19
+ return 0
20
+ }
21
+ const [file, ...rest] = args
22
+ if (file === undefined || rest.length > 0) {
23
+ console.error(USAGE)
24
+ return 1
25
+ }
26
+ let text: string
27
+ try {
28
+ text = readFileSync(file, 'utf8')
29
+ } catch {
30
+ console.error(`cannot read ${file}`)
31
+ return 1
32
+ }
33
+ const problems = mermaidProblems(text)
34
+ for (const line of problemLines(file, problems)) console.error(line)
35
+ return problems.length === 0 ? 0 : 1
36
+ }
@@ -0,0 +1,48 @@
1
+ /** A line of a Mermaid block that GitHub fails to draw: its 1-based line in the text, the line itself and why. */
2
+ export type MermaidProblem = { line: number; text: string; reason: string }
3
+
4
+ const FENCE = /^\s*(```|~~~)\s*(\S*)/
5
+ const FLOWCHART = /^\s*(?:flowchart|graph)\b/
6
+ /** A label written between the dashes of the link, `a -- text --> b`: a `--` inside the text ends the link early. */
7
+ const LABEL_BETWEEN_DASHES = /\s(?:--|==)\s+\S.*?\s(?:-->|---|==>)(?:\s|$)/
8
+ /** A pipe label that does not open with a quote: a comma, colon or parenthesis in it breaks the parser. */
9
+ const UNQUOTED_PIPE_LABEL = /(?:-->|---|-\.->|==>)\|[^"|]/
10
+
11
+ const REASON = {
12
+ betweenDashes: 'edge label between dashes: write -->|"label"|',
13
+ unquoted: 'edge label without quotes: write -->|"label"|',
14
+ } as const
15
+
16
+ const reasonOf = (text: string): string | null => {
17
+ if (LABEL_BETWEEN_DASHES.test(text)) return REASON.betweenDashes
18
+ return UNQUOTED_PIPE_LABEL.test(text) ? REASON.unquoted : null
19
+ }
20
+
21
+ /**
22
+ * The edge labels in the flowcharts of `markdown` that GitHub does not draw: ones written between the dashes and
23
+ * pipe labels without quotes. Only fenced blocks marked `mermaid` are read (a block never closed runs to the end of the
24
+ * text); the form that always parses is `-->|"label"|`.
25
+ */
26
+ export const mermaidProblems = (markdown: string): MermaidProblem[] => {
27
+ const problems: MermaidProblem[] = []
28
+ let fence: string | null = null
29
+ let isFlowchart = false
30
+ markdown.split('\n').forEach((text, index) => {
31
+ const opening = FENCE.exec(text)
32
+ if (fence === null) {
33
+ if (opening?.[2] === 'mermaid') {
34
+ fence = opening[1] ?? '```'
35
+ isFlowchart = false
36
+ }
37
+ return
38
+ }
39
+ if (opening && opening[1] === fence && opening[2] === '') {
40
+ fence = null
41
+ return
42
+ }
43
+ if (FLOWCHART.test(text)) isFlowchart = true
44
+ const reason = isFlowchart ? reasonOf(text) : null
45
+ if (reason) problems.push({ line: index + 1, text: text.trim(), reason })
46
+ })
47
+ return problems
48
+ }
@@ -81,10 +81,10 @@ const dependsOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency,
81
81
  /**
82
82
  * A path as the cell writes it, cut to the directory a glob character starts in (`scripts/*.ts` is `scripts`,
83
83
  * `.claude/**` is `.claude`, `*.md` is the root `''`) and without a trailing slash; null for a backticked entry with a
84
- * space in it, which is a command.
84
+ * space in it, which is a command, and for one with `[]`, which is a config key (`watch.sources[].label`) and no glob.
85
85
  */
86
86
  const pathOf = (entry: string): string | null => {
87
- if (/\s/.test(entry)) return null
87
+ if (/\s/.test(entry) || entry.includes('[]')) return null
88
88
  const glob = entry.search(GLOB_CHARACTER)
89
89
  const literal = glob === -1 ? entry : entry.slice(0, Math.max(entry.lastIndexOf('/', glob), 0))
90
90
  const path = literal.replace(/^\.\//, '').replace(/\/+$/, '')
@@ -12,6 +12,10 @@ export type WaitingItem = {
12
12
  since: string
13
13
  item: string
14
14
  open: boolean
15
+ /** The board row's origin cell, one line; absent when the table has no such column or the cell is empty. */
16
+ origin?: string
17
+ /** What the herd shows before the item (the label of the source the row came from); set by the herd, never read from the board. */
18
+ prefix?: string
15
19
  /** A `file:` address the item links to, for a sent file; absent for a row of the board. */
16
20
  url?: string
17
21
  /** Absent for a row of the board. */
@@ -30,7 +34,7 @@ const isOpen = (status: string, language: PlanLanguage): boolean => {
30
34
 
31
35
  /**
32
36
  * The rows of the board's waiting section in `language` (`## Wartet auf dich`, `## Waiting for you`), its columns
33
- * found by their header names in any order; the origin column is not read. A table without the since, item or status
37
+ * found by their header names in any order; the origin column is read when the table has it and the cell is not empty. A table without the since, item or status
34
38
  * column, a section without a table and a row without an item give nothing.
35
39
  */
36
40
  export const readWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] => {
@@ -40,11 +44,20 @@ export const readWaitingItems = (board: string, language: PlanLanguage): Waiting
40
44
  const positions = [columns.since, columns.item, statusName].map((name) => header.indexOf(name))
41
45
  if (positions.includes(-1)) return []
42
46
  const [since, item, status] = positions
47
+ const originPosition = header.indexOf(columns.origin)
43
48
  return rows.flatMap((cells) => {
44
49
  const text = oneLine(cells[item] ?? '')
50
+ const origin = oneLine(cells[originPosition] ?? '')
45
51
  return text === ''
46
52
  ? []
47
- : [{ since: oneLine(cells[since] ?? ''), item: text, open: isOpen(cells[status] ?? '', language) }]
53
+ : [
54
+ {
55
+ since: oneLine(cells[since] ?? ''),
56
+ item: text,
57
+ open: isOpen(cells[status] ?? '', language),
58
+ ...(origin === '' ? {} : { origin }),
59
+ },
60
+ ]
48
61
  })
49
62
  }
50
63
 
@@ -1,13 +1,15 @@
1
- import { twoDigits } from '../plan-format/clock'
1
+ import { DAY_MS, twoDigits } from '../plan-format/clock'
2
2
  import { padVisible } from '../terminal/text'
3
3
  import { textWidth } from '../terminal/width'
4
4
  import type { PlanLanguage } from '../vocabulary'
5
5
  import { type Appointment, readAppointmentTable } from '../watch/appointments'
6
+ import { ASK_KIND } from './ask-target'
7
+ import { DONE_KIND } from './row-targets'
6
8
  import { shortTitle } from './task-name'
7
- import type { BlockItem } from './waiting-click'
9
+ import { type BlockButton, type BlockItem, DONE_BUTTON, MOVE_BUTTON, RUN_BUTTON } from './waiting-click'
8
10
 
9
11
  /** An open appointment as the herd shows it: `label` is the weekday and the time, `Mon 09:03` (the weekday is English whatever the board's language); `key` is the watcher's. */
10
- export type AppointmentRow = { key: string; label: string; action: string }
12
+ export type AppointmentRow = { key: string; when: string; label: string; action: string }
11
13
 
12
14
  /** The appointment block: the open rows, the earliest first. */
13
15
  export type AppointmentBlock = { rows: AppointmentRow[] }
@@ -18,11 +20,26 @@ const WEEKDAY_LOCALE = 'en-US'
18
20
 
19
21
  const weekdayOf = (when: Date): string => new Intl.DateTimeFormat(WEEKDAY_LOCALE, { weekday: 'short' }).format(when)
20
22
 
21
- const rowOf = ({ when, action, key }: Appointment): AppointmentRow => ({
22
- key,
23
- label: `${weekdayOf(when)} ${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())}`,
24
- action,
25
- })
23
+ const DATE_FROM_DAYS_AHEAD = 7
24
+
25
+ const startOfDay = (when: Date): number => new Date(when.getFullYear(), when.getMonth(), when.getDate()).getTime()
26
+
27
+ const daysAhead = (when: Date, now: Date): number => Math.round((startOfDay(when) - startOfDay(now)) / DAY_MS)
28
+
29
+ /** The weekday names a day within the week; from a week on it is the date, `13.01.`, since the weekday would repeat. */
30
+ const dayOf = (when: Date, now: Date): string =>
31
+ daysAhead(when, now) >= DATE_FROM_DAYS_AHEAD
32
+ ? `${twoDigits(when.getDate())}.${twoDigits(when.getMonth() + 1)}.`
33
+ : weekdayOf(when)
34
+
35
+ const rowOf =
36
+ (now: Date) =>
37
+ ({ when, whenText, action, key }: Appointment): AppointmentRow => ({
38
+ key,
39
+ when: whenText,
40
+ label: `${dayOf(when, now)} ${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())}`,
41
+ action,
42
+ })
26
43
 
27
44
  /**
28
45
  * The open appointments of the board's section, read as the watcher reads them, the earliest first. A done row, a
@@ -32,7 +49,7 @@ export const appointmentRows = (board: string, language: PlanLanguage, now: Date
32
49
  readAppointmentTable(board, language, now)
33
50
  .appointments.filter(({ open }) => open)
34
51
  .sort((left, right) => left.when.getTime() - right.when.getTime())
35
- .map(rowOf)
52
+ .map(rowOf(now))
36
53
 
37
54
  /** The block of the board's open appointments, as the herd shows them. */
38
55
  export const appointmentBlock = (board: string, language: PlanLanguage, now: Date): AppointmentBlock => ({
@@ -41,7 +58,13 @@ export const appointmentBlock = (board: string, language: PlanLanguage, now: Dat
41
58
 
42
59
  const appointmentKey = ({ key }: AppointmentRow): string => `appointment>${key}`
43
60
 
44
- /** The rows as the block draws them; a click on one only expands it, so they carry no address and no question. */
61
+ const buttonsOf = ({ when, action }: AppointmentRow): BlockButton[] => [
62
+ { label: DONE_BUTTON, click: { done: { kind: DONE_KIND.appointment, when, action } } },
63
+ { label: RUN_BUTTON, click: { ask: { kind: ASK_KIND.appointmentRun, when, action } } },
64
+ { label: MOVE_BUTTON, click: { ask: { kind: ASK_KIND.appointmentMove, when, action } } },
65
+ ]
66
+
67
+ /** The rows as the block draws them; a click on the row only expands it, the buttons close it, run it or move it. */
45
68
  export const appointmentItems = (rows: readonly AppointmentRow[]): BlockItem[] => {
46
69
  const timeWidth = Math.max(0, ...rows.map(({ label }) => textWidth(label)))
47
70
  return rows.map((row) => ({
@@ -52,6 +75,7 @@ export const appointmentItems = (rows: readonly AppointmentRow[]): BlockItem[] =
52
75
  title: shortTitle(row.action),
53
76
  url: null,
54
77
  ask: null,
78
+ buttons: buttonsOf(row),
55
79
  }))
56
80
  }
57
81
 
@@ -17,34 +17,68 @@ export type AskDependencies = {
17
17
  run: HerdrRunner
18
18
  }
19
19
 
20
- type AskSentences = { question: (id: string) => string; boardRow: (key: string) => string }
20
+ /** A sentence takes the subject's key and, for an entry of a source, the source it belongs to (else `''`). */
21
+ type AskSentences = Record<AskTarget['kind'], (subject: string, scope: string) => string>
21
22
 
22
23
  const ASK_LINES: Record<PlanLanguage, AskSentences> = {
23
24
  [PLAN_LANGUAGE.de]: {
24
- question: (id) => `Stell mir die offene Frage ${id} jetzt (jh waiting).`,
25
- boardRow: (key) => `Stell mir die offene Board-Zeile \`${key}\` jetzt (jh waiting).`,
25
+ [ASK_KIND.question]: (id) => `Stell mir die offene Frage ${id} jetzt (jh waiting).`,
26
+ [ASK_KIND.boardRow]: (key) => `Stell mir die offene Board-Zeile \`${key}\` jetzt (jh waiting).`,
27
+ [ASK_KIND.appointmentRun]: (key) => `Führe den Termin \`${key}\` jetzt aus, unter den Freigaben, die er braucht.`,
28
+ [ASK_KIND.appointmentMove]: (key) =>
29
+ `Frag den User per Dialog nach der neuen Zeit für den Termin \`${key}\` und trag sie ins Board ein (verschobene Zeile = neuer Termin).`,
30
+ [ASK_KIND.sourceEntry]: (title, source) =>
31
+ `Sieh dir die Meldung \`${title}\` der Quelle \`${source}\` an und stell mir, falls nötig, die Frage dazu (jh waiting).`,
26
32
  },
27
33
  [PLAN_LANGUAGE.en]: {
28
- question: (id) => `Ask me the open question ${id} now (jh waiting).`,
29
- boardRow: (key) => `Ask me the open board row \`${key}\` now (jh waiting).`,
34
+ [ASK_KIND.question]: (id) => `Ask me the open question ${id} now (jh waiting).`,
35
+ [ASK_KIND.boardRow]: (key) => `Ask me the open board row \`${key}\` now (jh waiting).`,
36
+ [ASK_KIND.appointmentRun]: (key) => `Run the appointment \`${key}\` now, under the approvals it needs.`,
37
+ [ASK_KIND.appointmentMove]: (key) =>
38
+ `Ask the user in a dialog for the new time of the appointment \`${key}\` and write it into the board (a moved row is a new appointment).`,
39
+ [ASK_KIND.sourceEntry]: (title, source) =>
40
+ `Look at the report \`${title}\` of the source \`${source}\` and, if it needs it, put the question about it to me (jh waiting).`,
30
41
  },
31
42
  }
32
43
 
44
+ /** What the sentence names: the id of a question, else the sanitised key of the row (`boardRowKey`). */
45
+ const subjectKeyOf = (target: AskTarget): string => {
46
+ if (target.kind === ASK_KIND.question) return target.id
47
+ if (target.kind === ASK_KIND.sourceEntry) return boardRowKey('', target.title)
48
+ return target.kind === ASK_KIND.boardRow
49
+ ? boardRowKey(target.since, target.item)
50
+ : boardRowKey(target.when, target.action)
51
+ }
52
+
53
+ /** The source of an entry, sanitised like the key; `''` for every other target. */
54
+ const scopeOf = (target: AskTarget): string =>
55
+ target.kind === ASK_KIND.sourceEntry ? boardRowKey('', target.source) : ''
56
+
33
57
  /**
34
58
  * The line sent to the coordinator: a fixed sentence that carries the id of a question, or the sanitised key of a
35
- * board row (`boardRowKey`), and nothing else from the board or the question, so a prompt line cannot be steered by
59
+ * row (`boardRowKey`), and nothing else from the board or the question, so a prompt line cannot be steered by
36
60
  * the file it reads. The caller checks the id with `isQuestionId`.
37
61
  */
38
62
  export const askLine = (target: AskTarget, language: PlanLanguage): string =>
39
- target.kind === ASK_KIND.question
40
- ? ASK_LINES[language].question(target.id)
41
- : ASK_LINES[language].boardRow(boardRowKey(target.since, target.item))
63
+ ASK_LINES[language][target.kind](subjectKeyOf(target), scopeOf(target))
42
64
 
43
65
  const isAskable = (target: AskTarget): boolean =>
44
- target.kind === ASK_KIND.question ? isQuestionId(target.id) : boardRowKey(target.since, target.item) !== ''
66
+ target.kind === ASK_KIND.question
67
+ ? isQuestionId(target.id)
68
+ : subjectKeyOf(target) !== '' && (target.kind !== ASK_KIND.sourceEntry || scopeOf(target) !== '')
69
+
70
+ const SUBJECT_NAMES: Record<AskTarget['kind'], string> = {
71
+ [ASK_KIND.question]: 'question',
72
+ [ASK_KIND.boardRow]: 'board row',
73
+ [ASK_KIND.appointmentRun]: 'appointment',
74
+ [ASK_KIND.appointmentMove]: 'appointment',
75
+ [ASK_KIND.sourceEntry]: 'report',
76
+ }
45
77
 
46
- const subjectOf = (target: AskTarget): string =>
47
- target.kind === ASK_KIND.question ? `question ${target.id}` : `board row ${boardRowKey(target.since, target.item)}`
78
+ const subjectOf = (target: AskTarget): string => {
79
+ const scope = scopeOf(target)
80
+ return `${SUBJECT_NAMES[target.kind]} ${subjectKeyOf(target)}${scope === '' ? '' : ` of ${scope}`}`
81
+ }
48
82
 
49
83
  const agentNamesOf = (run: HerdrRunner): string[] => parseAgentList(run(['agent', 'list'])).map(({ name }) => name)
50
84
 
@@ -1,11 +1,22 @@
1
1
  import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
2
2
 
3
- /** What the coordinator can be asked to put to the user: an open dialog question, or an open row of the board. */
4
- export const ASK_KIND = { question: 'question', boardRow: 'boardRow' } as const
3
+ /**
4
+ * What the coordinator can be asked: to put an open dialog question or an open board row to the user, to run an
5
+ * appointment now, or to ask the user for a new time for it.
6
+ */
7
+ export const ASK_KIND = {
8
+ question: 'question',
9
+ boardRow: 'boardRow',
10
+ appointmentRun: 'appointmentRun',
11
+ appointmentMove: 'appointmentMove',
12
+ sourceEntry: 'sourceEntry',
13
+ } as const
5
14
 
6
15
  export type AskTarget =
7
16
  | { kind: typeof ASK_KIND.question; id: string }
8
17
  | { kind: typeof ASK_KIND.boardRow; since: string; item: string }
18
+ | { kind: typeof ASK_KIND.appointmentRun | typeof ASK_KIND.appointmentMove; when: string; action: string }
19
+ | { kind: typeof ASK_KIND.sourceEntry; source: string; title: string }
9
20
 
10
21
  /** The longest key a prompt line carries for a board row, the ellipsis included. */
11
22
  export const MAX_ROW_KEY_LENGTH = 120