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.
- package/CHANGELOG.md +19 -0
- package/README.md +17 -2
- package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
- package/dist/config.d.ts +60 -0
- package/dist/config.js +39 -4
- package/dist/plan-format.d.ts +7 -1
- package/dist/plan-format.js +4 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/open-question/run.ts +8 -3
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +10 -2
- package/package.json +1 -1
- package/recipes/sources.md +4 -0
- package/schemas/jackherd.schema.json +80 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +24 -1
- package/src/config/tones.ts +27 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/command.ts +8 -2
- package/src/lane-prompt/input.ts +44 -3
- package/src/lane-prompt/options.ts +2 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/dependencies.ts +2 -2
- package/src/plan-format/waiting.ts +15 -2
- package/src/plans/appointment-rows.ts +34 -10
- package/src/plans/ask-coordinator.ts +46 -12
- package/src/plans/ask-target.ts +13 -2
- package/src/plans/board-close.ts +85 -0
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +35 -0
- package/src/plans/groomed.ts +27 -0
- package/src/plans/layout-phase.ts +7 -4
- package/src/plans/layout.ts +258 -48
- package/src/plans/link-chips.ts +22 -4
- package/src/plans/live.ts +73 -5
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +14 -1
- package/src/plans/settings.ts +10 -0
- package/src/plans/snapshot.ts +79 -21
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/texts.ts +28 -3
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +8 -2
- package/src/plans/waiting-click.ts +129 -24
- package/src/terminal/text.ts +8 -0
- package/src/terminal/types.ts +4 -0
- package/src/vocabulary/index.ts +10 -0
- package/src/watch/plan-badges-read.ts +9 -2
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +41 -1
package/src/config/schema.ts
CHANGED
|
@@ -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
|
|
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>)"`.
|
|
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)
|
package/src/lane-prompt/input.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
: [
|
|
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
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/plans/ask-target.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
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
|