jackherd 1.11.1 → 1.12.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 (94) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +76 -58
  3. package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +15 -5
  6. package/dist/plan-format.d.ts +11 -6
  7. package/dist/plan-format.js +8 -2
  8. package/docs/follow-dark.svg +2 -2
  9. package/docs/follow.svg +2 -2
  10. package/docs/workflow-dark.svg +56 -64
  11. package/docs/workflow.svg +56 -64
  12. package/herdr-plugin/herdr-plugin.toml +1 -1
  13. package/install.sh +1 -1
  14. package/kit/workflow/skills/implement/SKILL.md +1 -1
  15. package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
  16. package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
  17. package/kit/workflow/skills/orchestrate/references/events.md +8 -1
  18. package/kit/workflow/skills/plan/SKILL.md +2 -0
  19. package/package.json +2 -2
  20. package/schemas/jackherd.schema.json +18 -1
  21. package/src/config/link-kinds.ts +1 -1
  22. package/src/config/schema.ts +5 -2
  23. package/src/config/tones.ts +9 -2
  24. package/src/context/fill.ts +4 -10
  25. package/src/context/session-log.ts +108 -2
  26. package/src/plan-format/lanes.ts +10 -1
  27. package/src/plan-format/phases.ts +11 -3
  28. package/src/plan-format/progress.ts +4 -2
  29. package/src/plan-format/waiting.ts +12 -0
  30. package/src/plans/active-tasks.ts +31 -8
  31. package/src/plans/appointment-rows.ts +42 -53
  32. package/src/plans/board-rows.ts +14 -0
  33. package/src/plans/calendar-days.ts +29 -0
  34. package/src/plans/click-hint.ts +94 -0
  35. package/src/plans/command.ts +8 -9
  36. package/src/plans/current-phase.ts +22 -10
  37. package/src/plans/current-task.ts +18 -3
  38. package/src/plans/frame.ts +18 -26
  39. package/src/plans/git.ts +90 -31
  40. package/src/plans/groomed.ts +13 -7
  41. package/src/plans/lane-reason.ts +61 -0
  42. package/src/plans/lane-summary.ts +75 -24
  43. package/src/plans/layout-phase.ts +123 -76
  44. package/src/plans/layout-summary.ts +133 -83
  45. package/src/plans/layout.ts +451 -345
  46. package/src/plans/legend.ts +29 -0
  47. package/src/plans/link-chips.ts +26 -8
  48. package/src/plans/live.ts +238 -84
  49. package/src/plans/next-rows.ts +5 -37
  50. package/src/plans/phase-source.ts +8 -3
  51. package/src/plans/plan-chips.ts +6 -3
  52. package/src/plans/plan-heads.ts +1 -1
  53. package/src/plans/planned.ts +4 -1
  54. package/src/plans/process-slots.ts +36 -0
  55. package/src/plans/pull-requests.ts +9 -1
  56. package/src/plans/read-cache.ts +18 -3
  57. package/src/plans/refresh-pace.ts +13 -0
  58. package/src/plans/row-focus.ts +86 -15
  59. package/src/plans/selection.ts +124 -89
  60. package/src/plans/sent-files.ts +1 -1
  61. package/src/plans/settings.ts +5 -2
  62. package/src/plans/shimmer.ts +124 -0
  63. package/src/plans/signals-read.ts +38 -10
  64. package/src/plans/slice-gate.ts +51 -0
  65. package/src/plans/snapshot.ts +185 -84
  66. package/src/plans/source-asked.ts +1 -1
  67. package/src/plans/source-rows.ts +60 -18
  68. package/src/plans/sprint-html.ts +1 -1
  69. package/src/plans/status-pace.ts +45 -0
  70. package/src/plans/subagent-tasks.ts +1 -1
  71. package/src/plans/subagents-read.ts +61 -24
  72. package/src/plans/subagents.ts +2 -2
  73. package/src/plans/symbols.ts +4 -1
  74. package/src/plans/task-brief.ts +143 -0
  75. package/src/plans/texts.ts +181 -39
  76. package/src/plans/view.ts +198 -32
  77. package/src/plans/waiting-click.ts +54 -110
  78. package/src/plans/watch-burst.ts +25 -0
  79. package/src/plans/worktree-stamp.ts +35 -0
  80. package/src/plans/your-turn-rows.ts +102 -0
  81. package/src/plans/your-turn.ts +226 -0
  82. package/src/terminal/herdr.ts +25 -5
  83. package/src/terminal/restore.ts +44 -0
  84. package/src/terminal/text.ts +2 -2
  85. package/src/terminal/width.ts +9 -0
  86. package/src/vocabulary/index.ts +6 -5
  87. package/src/watch/command.ts +28 -2
  88. package/src/watch/lane-report.ts +2 -0
  89. package/src/watch/lane-rest.ts +7 -1
  90. package/src/watch/reference-paths.ts +1 -0
  91. package/src/watch/unlinked-artifacts-read.ts +43 -0
  92. package/src/watch/unlinked-artifacts.ts +76 -0
  93. package/src/watch/watch-events.ts +12 -0
  94. package/src/watch/watcher-lock.ts +47 -4
@@ -0,0 +1,45 @@
1
+ import type { LaneStatus } from './layout'
2
+
3
+ /** What `git status` tells the herd about a worktree. */
4
+ export type BranchStatus = Pick<LaneStatus, 'branch' | 'dirty'>
5
+
6
+ /** The last `git status` of a worktree, with the stamp of the worktree it was asked at and when (epoch milliseconds). */
7
+ export type KeptStatus = { stamp: string; at: number; status: BranchStatus }
8
+
9
+ /** Where and when a status is asked: the worktree, its stamp (`worktreeStamp`) and the time. */
10
+ export type StatusAsk = { worktree: string; stamp: string; now: number }
11
+
12
+ /**
13
+ * How long a kept `git status` holds while its worktree's stamp stays the same: a change made by hand, which moves
14
+ * neither the index nor HEAD nor an agent's log, shows within this time.
15
+ */
16
+ export const STATUS_PACE = { maxAgeMs: 60_000 } as const
17
+
18
+ /** How much of an agent's last log line the stamp keeps: its end carries the entry's id and time. */
19
+ const LOG_MARK_CHARS = 200
20
+
21
+ const isFresh = (kept: KeptStatus | undefined, { stamp, now }: StatusAsk): kept is KeptStatus =>
22
+ kept !== undefined && kept.stamp === stamp && now - kept.at < STATUS_PACE.maxAgeMs
23
+
24
+ /** What the agent's log (its last lines, as the herd read them) says of its latest entry; empty without a log. */
25
+ export const logMark = (lines: readonly string[] | null): string => {
26
+ if (!lines) return ''
27
+ const last = lines.at(-1) ?? ''
28
+ return `${lines.length}:${last.length}:${last.slice(-LOG_MARK_CHARS)}`
29
+ }
30
+
31
+ /**
32
+ * The status of `ask.worktree`: the kept one while its stamp is unchanged and younger than `STATUS_PACE.maxAgeMs`,
33
+ * else `read` asked again and kept in `kept` with the stamp and time.
34
+ */
35
+ export const pacedStatus = async (
36
+ kept: Map<string, KeptStatus>,
37
+ ask: StatusAsk,
38
+ read: () => Promise<BranchStatus>,
39
+ ): Promise<BranchStatus> => {
40
+ const previous = kept.get(ask.worktree)
41
+ if (isFresh(previous, ask)) return previous.status
42
+ const status = await read()
43
+ kept.set(ask.worktree, { stamp: ask.stamp, at: ask.now, status })
44
+ return status
45
+ }
@@ -29,7 +29,7 @@ const startsWithName = (description: string, name: string): boolean =>
29
29
 
30
30
  /**
31
31
  * The open task each subagent works on, from the short name its description starts with (`/implement` Step 0b, case
32
- * ignored): it gets its `▶` like a reported task, one per task however many subagents share it. A ticked task and a
32
+ * ignored): it gets its `●` like a reported task, one per task however many subagents share it. A ticked task and a
33
33
  * review gate item take none; a subagent whose description starts with no name stays where it was.
34
34
  */
35
35
  export const subagentTaskPlacements = (
@@ -1,6 +1,6 @@
1
1
  import { readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { defaultProjectsDirectory, projectDirectoryOf, readTailLines } from '../context/session-log'
3
+ import { defaultProjectsDirectory, followTailLines, projectDirectoryOf } from '../context/session-log'
4
4
  import { MS_PER_MINUTE } from '../plan-format/clock'
5
5
  import { DEFAULT_SKILL_PREFIX } from '../sync/init'
6
6
  import { readApprovalRequests } from '../watch/approvals'
@@ -48,10 +48,24 @@ const directoriesOf = (path: string): string[] => {
48
48
  }
49
49
  }
50
50
 
51
- /** The entry names of `path`; none when it is missing or cannot be read. */
51
+ /** How many directories keep their listing: one `subagents` directory per session, a few hundred in a busy project. */
52
+ const LISTED_DIRECTORIES = 2048
53
+
54
+ const listings = new Map<string, { modifiedAt: number; names: string[] }>()
55
+
56
+ /**
57
+ * The entry names of `path`, listed again only when the directory changed since (a file added or removed moves its
58
+ * modification time, a write into a file does not); none when it is missing or cannot be read.
59
+ */
52
60
  const namesIn = (path: string): string[] => {
53
61
  try {
54
- return readdirSync(path)
62
+ const modifiedAt = statSync(path).mtimeMs
63
+ const kept = listings.get(path)
64
+ if (kept?.modifiedAt === modifiedAt) return kept.names
65
+ const names = readdirSync(path)
66
+ if (listings.size >= LISTED_DIRECTORIES) listings.clear()
67
+ listings.set(path, { modifiedAt, names })
68
+ return names
55
69
  } catch {
56
70
  return []
57
71
  }
@@ -63,22 +77,23 @@ const logOf = (directory: string, name: string, id: string, now: number): Subage
63
77
  const modifiedAt = statSync(logPath).mtimeMs
64
78
  if (now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE) return []
65
79
  const meta: unknown = JSON.parse(readFileSync(join(directory, name), 'utf8'))
66
- return [{ id, meta, modifiedAt, tail: readTailLines(logPath, TAIL_BYTES) }]
80
+ return [{ id, meta, modifiedAt, tail: followTailLines(logPath, TAIL_BYTES) }]
67
81
  } catch {
68
82
  return []
69
83
  }
70
84
  }
71
85
 
72
- /** The subagent logs of every session in one project directory. */
73
- const sessionLogsOf = (project: string, now: number): SubagentLog[] =>
74
- directoriesOf(project).flatMap((session) => {
75
- const directory = join(project, session, 'subagents')
76
- return namesIn(directory).flatMap((name) => {
77
- const id = META_FILE.exec(name)?.[1]
78
- return id ? logOf(directory, name, id, now) : []
79
- })
86
+ /** The subagent logs of one session's `subagents` directory. */
87
+ const logsInSession = (directory: string, now: number): SubagentLog[] =>
88
+ namesIn(directory).flatMap((name) => {
89
+ const id = META_FILE.exec(name)?.[1]
90
+ return id ? logOf(directory, name, id, now) : []
80
91
  })
81
92
 
93
+ /** The `subagents` directory of every session in one project directory. */
94
+ const sessionDirectoriesOf = (project: string): string[] =>
95
+ directoriesOf(project).map((session) => join(project, session, 'subagents'))
96
+
82
97
  /**
83
98
  * The project directories Claude Code keeps for sessions started in the main checkout `root` and, when
84
99
  * `worktreeDirectory` is given, in a worktree below it: a lane's own subagents, its reviews among them, live there.
@@ -89,6 +104,10 @@ const projectDirectoriesOf = (projects: string, root: string, worktreeDirectory?
89
104
  return directoriesOf(projects).filter((name) => name === main || (lanes !== null && name.startsWith(lanes)))
90
105
  }
91
106
 
107
+ /** The `subagents` directory of every session of the main checkout and its worktrees. */
108
+ const allSessionDirectories = (projects: string, root: string, worktreeDirectory?: string): string[] =>
109
+ projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) => sessionDirectoriesOf(join(projects, name)))
110
+
92
111
  /**
93
112
  * The subagents with a recently written log in the sessions started in the main checkout `root` and its worktrees,
94
113
  * from Claude Code's `<projects>/<project>/<session>/subagents/agent-<id>.{meta.json,jsonl}`; whatever cannot be read
@@ -100,7 +119,7 @@ export const readSubagentLogs = (
100
119
  now: number,
101
120
  worktreeDirectory?: string,
102
121
  ): SubagentLog[] =>
103
- projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) => sessionLogsOf(join(projects, name), now))
122
+ allSessionDirectories(projects, root, worktreeDirectory).flatMap((directory) => logsInSession(directory, now))
104
123
 
105
124
  /** The names the gate holds an open request under, as the watcher reads them. */
106
125
  const heldNames = (root: string, now: number): Set<string> =>
@@ -110,22 +129,40 @@ const heldNames = (root: string, now: number): Set<string> =>
110
129
  .map((request) => request.lane),
111
130
  )
112
131
 
113
- /**
114
- * The subagents at work in the sessions of the main checkout and its worktrees: their logs read, the finished and the
115
- * stale left out. The one chain the herd (`readSubagentActivity`) and the watcher's heartbeat share.
116
- */
117
- export const readRunningSubagents = (
118
- { projects, root, approvalsRoot, worktreeDirectory, skillPrefix }: SubagentSource,
119
- now: number = Date.now(),
132
+ /** The running subagents among `logs`, the finished and the stale left out. */
133
+ const runningOf = (
134
+ logs: readonly SubagentLog[],
135
+ { approvalsRoot, worktreeDirectory, skillPrefix }: SubagentSource,
136
+ now: number,
120
137
  ): RunningSubagent[] =>
121
138
  runningSubagents(
122
- readSubagentLogs(projects, root, now, worktreeDirectory),
139
+ logs,
123
140
  heldNames(approvalsRoot, now),
124
141
  now,
125
142
  worktreeDirectory,
126
143
  skillPrefix === undefined ? [DEFAULT_SKILL_PREFIX] : [DEFAULT_SKILL_PREFIX, skillPrefix],
127
144
  )
128
145
 
129
- /** The state of every worktree a subagent of the main checkout's sessions works in. */
130
- export const readSubagentActivity = (source: SubagentSource, now: number = Date.now()): Map<string, SubagentActivity> =>
131
- subagentsByLane(readRunningSubagents(source, now), source.worktreeDirectory)
146
+ /**
147
+ * The subagents at work in the sessions of the main checkout and its worktrees: their logs read, the finished and the
148
+ * stale left out. The one chain the herd (`readSubagentActivitySliced`) and the watcher's heartbeat share.
149
+ */
150
+ export const readRunningSubagents = (source: SubagentSource, now: number = Date.now()): RunningSubagent[] =>
151
+ runningOf(readSubagentLogs(source.projects, source.root, now, source.worktreeDirectory), source, now)
152
+
153
+ /**
154
+ * The state of every worktree a subagent of the main checkout's sessions works in, with `pass` awaited before each
155
+ * session's subagents are read, so a gate (`createSliceGate`) can hand the event loop back between them.
156
+ */
157
+ export const readSubagentActivitySliced = async (
158
+ source: SubagentSource,
159
+ pass: () => Promise<void>,
160
+ now: number = Date.now(),
161
+ ): Promise<Map<string, SubagentActivity>> => {
162
+ const logs: SubagentLog[] = []
163
+ for (const directory of allSessionDirectories(source.projects, source.root, source.worktreeDirectory)) {
164
+ await pass()
165
+ logs.push(...logsInSession(directory, now))
166
+ }
167
+ return subagentsByLane(runningOf(logs, source, now), source.worktreeDirectory)
168
+ }
@@ -1,4 +1,4 @@
1
- import { parseLogEntry } from '../context/session-log'
1
+ import { keptLogEntry } from '../context/session-log'
2
2
  import { MS_PER_MINUTE } from '../plan-format/clock'
3
3
  import { terminalSafe } from '../plan-format/terminal-text'
4
4
  import { isRecord } from '../shared/is-record'
@@ -86,7 +86,7 @@ const lastWorktreeIn = (text: string, worktreeDirectory: string): string | null
86
86
  */
87
87
  const workingDirectory = (tail: readonly string[], worktreeDirectory: string): string | null => {
88
88
  const entries = [...tail].reverse().flatMap((line): Record<string, unknown>[] => {
89
- const entry = parseLogEntry(line)
89
+ const entry = keptLogEntry(line)
90
90
  return entry === null ? [] : [entry]
91
91
  })
92
92
  for (const entry of entries) {
@@ -18,7 +18,10 @@ export const statusSymbol = (agent: string | null): string => {
18
18
 
19
19
  export const statusWord = (agent: string | null): string => agent ?? NO_AGENT_WORD
20
20
 
21
- /** Before a row that opens and closes: lanes, the planned phases, the ticked items of a phase. */
21
+ /** The marks of "Your turn": what waits on the answer, a file sent to look at, what is planned. */
22
+ export const TURN_MARK = { now: '?', file: '📎', planned: '◷' } as const
23
+
24
+ /** Before a row that opens and closes: lanes, the planned phases, the tasks of a phase. */
22
25
  export const CHEVRON = { closed: '▸', open: '▾' } as const
23
26
 
24
27
  /** Before the count under a block that shows all its rows; a click folds it back to five. */
@@ -0,0 +1,143 @@
1
+ import { paint, visibleLength } from '../terminal/text'
2
+ import { textWidth } from '../terminal/width'
3
+
4
+ /** An opened task as the herd sets it: the lines shown at once, and the lines `▸ n more lines` opens. */
5
+ export type TaskBrief = { shown: string[]; rest: string[] }
6
+
7
+ /** A word as its parts, code or not: a code span and the stop after it are one word. */
8
+ type Word = { text: string; code: boolean }[]
9
+
10
+ /**
11
+ * How an opened task is set: the order up to the first `dashMark`, each sentence on a new line, as many whole
12
+ * sentences as fit in `lines` lines, the origin included, no line wider than `lineWidth`; an origin in parentheses
13
+ * moves to the end of the last line, `originGap` from it. A first sentence longer than `lines` ends in `cut`.
14
+ */
15
+ export const BRIEF = { lines: 5, lineWidth: 72, dashMark: ' — ', originGap: ' ', cut: '…' } as const
16
+
17
+ const CODE_SPAN = /`([^`]+)`/g
18
+ const BOLD = /\*\*/g
19
+ /**
20
+ * A parenthesis that names a day and month with dots: where a task came from, not what it asks. No digit may follow
21
+ * the second dot, so a version (`1.10.0`) or an IP address is no date.
22
+ */
23
+ const ORIGIN = /\s*\(([^()]*\b\d{1,2}\.\d{1,2}\.(?!\d)[^()]*)\)/g
24
+ /**
25
+ * A sentence ends before a capital after a word of two letters or more, or after a unit behind a number (`5 s.`), so
26
+ * a dotted date and `z. B.` go on.
27
+ */
28
+ const SENTENCE_END = /(?:(?<=[^\s\d][\p{L})"“”`'][.!?])|(?<=\d \p{L}[.!?]))\s+(?=[\p{Lu}„"`*(])/u
29
+ const SOURCE_DIRECTORY = /^src\//
30
+
31
+ /** The words of `text` at its spaces, code spans without backticks and a path's leading `src/` dropped. */
32
+ const wordsOf = (text: string): Word[] => {
33
+ const words: Word[] = [[]]
34
+ text
35
+ .replace(BOLD, '')
36
+ .split(CODE_SPAN)
37
+ .forEach((segment, index) => {
38
+ const code = index % 2 === 1
39
+ const parts = (code ? segment.replace(SOURCE_DIRECTORY, '') : segment).split(' ')
40
+ parts.forEach((part, at) => {
41
+ if (at > 0) words.push([])
42
+ if (part) words.at(-1)?.push({ text: part, code })
43
+ })
44
+ })
45
+ return words.filter((word) => word.length > 0)
46
+ }
47
+
48
+ const lengthOf = (word: Word): number => word.reduce((sum, part) => sum + textWidth(part.text), 0)
49
+
50
+ const drawn = (word: Word): string => word.map(({ text, code }) => (code ? paint.teal(text) : text)).join('')
51
+
52
+ /** `text` broken at spaces into lines of at most `width` cells, code in teal; a word wider than a line stands alone. */
53
+ const setLines = (text: string, width: number): string[] => {
54
+ const lines: Word[][] = []
55
+ let length = 0
56
+ for (const word of wordsOf(text)) {
57
+ const current = lines.at(-1)
58
+ if (current && length + 1 + lengthOf(word) <= width) {
59
+ current.push(word)
60
+ length += 1 + lengthOf(word)
61
+ } else {
62
+ lines.push([word])
63
+ length = lengthOf(word)
64
+ }
65
+ }
66
+ return lines.map((line) => line.map(drawn).join(' '))
67
+ }
68
+
69
+ const sentencesOf = (text: string): string[] =>
70
+ text
71
+ .split(SENTENCE_END)
72
+ .map((sentence) => sentence.trim())
73
+ .filter(Boolean)
74
+
75
+ /**
76
+ * `lines` with the origins dim at the end of the last one, or below it on lines of their own, broken at `width`, where
77
+ * they do not fit beside it.
78
+ */
79
+ const withOrigins = (lines: string[], origins: readonly string[], width: number): string[] => {
80
+ if (origins.length === 0) return lines
81
+ const origin = origins.join('; ')
82
+ const last = lines.at(-1)
83
+ if (last !== undefined && visibleLength(last) + BRIEF.originGap.length + textWidth(origin) <= width)
84
+ return [...lines.slice(0, -1), `${last}${BRIEF.originGap}${paint.dim(origin)}`]
85
+ return [...lines, ...setLines(origin, width).map((line) => paint.dim(line))]
86
+ }
87
+
88
+ const originsOf = (text: string): string[] => [...text.matchAll(ORIGIN)].map((match) => match[1]?.trim() ?? '')
89
+
90
+ /** `text` in the form of the brief: one sentence per line, its origins dim at the end. */
91
+ const setSentences = (text: string, width: number): string[] =>
92
+ withOrigins(
93
+ sentencesOf(text.replace(ORIGIN, '')).flatMap((sentence) => setLines(sentence, width)),
94
+ originsOf(text),
95
+ width,
96
+ )
97
+
98
+ const fits = (lines: string[], origins: readonly string[], width: number): boolean =>
99
+ withOrigins(lines, origins, width).length <= BRIEF.lines
100
+
101
+ /** How many of the leading sentences, each set as `lines`, fit whole into the brief together with the origins. */
102
+ const wholeSentenceCount = (sentenceLines: string[][], origins: readonly string[], width: number): number => {
103
+ let count = 0
104
+ while (count < sentenceLines.length && fits(sentenceLines.slice(0, count + 1).flat(), origins, width)) count += 1
105
+ return count
106
+ }
107
+
108
+ /** A sentence too long for the brief: as many of its lines as fit beside the origins, the last ending in `BRIEF.cut`. */
109
+ const cutSentence = (sentence: string, origins: readonly string[], width: number): TaskBrief => {
110
+ const lines = setLines(sentence, Math.max(width - textWidth(BRIEF.cut), 1))
111
+ const marked = (count: number): string[] =>
112
+ lines.slice(0, count).map((line, index) => (index === count - 1 ? `${line}${BRIEF.cut}` : line))
113
+ let count = BRIEF.lines
114
+ while (count > 1 && !fits(marked(count), origins, width)) count -= 1
115
+ return { shown: withOrigins(marked(count), origins, width), rest: lines.slice(count) }
116
+ }
117
+
118
+ /**
119
+ * An opened task's text (`body`, after its name) set for reading in `width` cells: the order up to the first ` — `,
120
+ * one sentence per line, as many whole sentences as fit into `BRIEF.lines` with their origins dim at the end; the rest
121
+ * (further sentences, then everything after the dash: protocol, measurements, findings) as `rest`. A first sentence
122
+ * that does not fit alone is cut instead. The plan text itself stays as written.
123
+ */
124
+ export const taskBrief = (body: string, width: number): TaskBrief => {
125
+ const lineWidth = Math.max(Math.min(BRIEF.lineWidth, width), 1)
126
+ const dash = body.indexOf(BRIEF.dashMark)
127
+ const order = dash === -1 ? body : body.slice(0, dash)
128
+ const after = dash === -1 ? '' : body.slice(dash + BRIEF.dashMark.length).trim()
129
+ const origins = originsOf(order)
130
+ const sentences = sentencesOf(order.replace(ORIGIN, ''))
131
+ const sentenceLines = sentences.map((sentence) => setLines(sentence, lineWidth))
132
+ const afterLines = after ? setSentences(after, lineWidth) : []
133
+ const count = wholeSentenceCount(sentenceLines, origins, lineWidth)
134
+ const [first] = sentences
135
+ if (count === 0 && first !== undefined) {
136
+ const cut = cutSentence(first, origins, lineWidth)
137
+ return { shown: cut.shown, rest: [...cut.rest, ...sentenceLines.slice(1).flat(), ...afterLines] }
138
+ }
139
+ return {
140
+ shown: withOrigins(sentenceLines.slice(0, count).flat(), origins, lineWidth),
141
+ rest: [...sentenceLines.slice(count).flat(), ...afterLines],
142
+ }
143
+ }