jackherd 1.7.0 → 1.8.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 +43 -0
- package/INSTALL.md +4 -2
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +94 -1033
- package/assets/icons/bark-dark.svg +1 -1
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl-dark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide-dark.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear-dark.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes-dark.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan-dark.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +73 -7
- package/dist/plan-format.js +180 -100
- package/docs/workflow-dark.svg +1 -1
- package/docs/workflow.svg +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
- package/kit/hooks/hooks/approval-gate/index.ts +10 -8
- package/kit/hooks/hooks/approval-gate/run.ts +21 -12
- package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
- package/kit/hooks/hooks/git-guard/decision.ts +189 -15
- package/kit/hooks/hooks/git-guard/index.ts +13 -3
- package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
- package/kit/hooks/hooks/git-guard/repository.ts +63 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
- package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
- package/kit/hooks/hooks/merge-guard/index.ts +6 -4
- package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
- package/kit/hooks/hooks/merge-guard/run.ts +118 -29
- package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
- package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
- package/kit/hooks/hooks/shared/base-branch.ts +23 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
- package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
- package/kit/hooks/hooks/shared/command.ts +14 -2
- package/kit/hooks/hooks/shared/commit.ts +2 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
- package/kit/hooks/hooks/shared/json.ts +10 -0
- package/kit/hooks/hooks/shared/lane.ts +57 -0
- package/kit/hooks/hooks/shared/once.ts +8 -0
- package/kit/hooks/hooks/shared/own-pane.ts +31 -0
- package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
- package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
- package/kit/hooks/hooks/shared/program-position.ts +148 -5
- package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
- package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
- package/kit/hooks/hooks/shared/shell.ts +167 -20
- package/kit/hooks/hooks.json +11 -2
- package/kit/hooks/statusline.sh +29 -16
- package/kit/review/skills/diagnose/SKILL.md +1 -1
- package/kit/review/skills/review/SKILL.md +2 -0
- package/kit/rules/rules/kit/testing.md +2 -5
- package/kit/rules/rules/kit/writing-for-agents.md +3 -1
- package/kit/workflow/agents/spec-review.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/grill/SKILL.md +1 -1
- package/kit/workflow/skills/implement/SKILL.md +10 -11
- package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
- package/kit/workflow/skills/plan/SKILL.md +5 -2
- package/kit/workflow/skills/plan/references/template-English.md +6 -0
- package/kit/workflow/skills/plan/references/template-German.md +6 -0
- package/package.json +3 -2
- package/recipes/README.md +21 -0
- package/recipes/github-issues.md +23 -0
- package/recipes/github-projects.md +22 -0
- package/recipes/jira-cli.md +26 -0
- package/recipes/jira-rest.md +24 -0
- package/recipes/linear.md +25 -0
- package/recipes/sentry.md +18 -0
- package/recipes/sources/jira.ts +51 -0
- package/recipes/sources/linear.ts +19 -0
- package/recipes/sources/sentry.ts +12 -0
- package/recipes/sources.md +80 -0
- package/recipes/status-page.md +19 -0
- package/schemas/jackherd.schema.json +42 -18
- package/src/check/command.ts +2 -2
- package/src/check/parallel-report.ts +19 -9
- package/src/cli.ts +1 -0
- package/src/config/context-shares.ts +130 -0
- package/src/config/legacy-names.ts +8 -0
- package/src/config/read-config.ts +12 -4
- package/src/config/schema.ts +75 -20
- package/src/context/auto-compact.ts +68 -0
- package/src/context/command.ts +68 -17
- package/src/context/coordinator-name.ts +27 -9
- package/src/context/index.ts +0 -1
- package/src/context/level.ts +18 -11
- package/src/context/report.ts +6 -3
- package/src/plan-format/dependencies.ts +144 -32
- package/src/plan-format/index.ts +6 -0
- package/src/plan-format/lanes.ts +6 -0
- package/src/plan-format/progress.ts +19 -8
- package/src/plans/command.ts +14 -5
- package/src/plans/current-phase.ts +63 -7
- package/src/plans/current-task.ts +51 -0
- package/src/plans/ensure-panes.ts +25 -8
- package/src/plans/frame.ts +99 -0
- package/src/plans/git.ts +8 -0
- package/src/plans/herd-memory.ts +107 -0
- package/src/plans/lane-plans.ts +5 -3
- package/src/plans/lane-summary.ts +222 -0
- package/src/plans/layout-phase.ts +148 -0
- package/src/plans/layout-summary.ts +140 -0
- package/src/plans/layout.ts +164 -420
- package/src/plans/live.ts +11 -6
- package/src/plans/plan-panes.ts +15 -0
- package/src/plans/planned.ts +9 -42
- package/src/plans/selection.ts +2 -6
- package/src/plans/settings.ts +2 -7
- package/src/plans/snapshot.ts +38 -39
- package/src/plans/symbols.ts +2 -2
- package/src/plans/texts.ts +32 -31
- package/src/plans/view.ts +0 -1
- package/src/plans/worked-phases.ts +2 -2
- package/src/settled/board-row.ts +133 -0
- package/src/settled/check.ts +129 -0
- package/src/settled/command.ts +65 -0
- package/src/settled/evidence-version.ts +14 -0
- package/src/settled/options.ts +69 -0
- package/src/settled/send.ts +27 -0
- package/src/settled/settle.ts +87 -0
- package/src/settled/topics.ts +56 -0
- package/src/sync/command.ts +16 -1
- package/src/sync/render.ts +13 -4
- package/src/sync/sync.ts +25 -5
- package/src/terminal/herdr.ts +46 -10
- package/src/terminal/none.ts +1 -0
- package/src/terminal/types.ts +15 -2
- package/src/update/command.ts +4 -2
- package/src/update/update.ts +33 -9
- package/src/vocabulary/index.ts +47 -11
- package/src/watch/appointments.ts +205 -0
- package/src/watch/command.ts +62 -19
- package/src/watch/lane-report.ts +11 -3
- package/src/watch/texts.ts +35 -59
- package/src/watch/watch-events.ts +21 -28
- package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { BAR_SEGMENT, INVERSE_ON, paint, RESET, stripAnsi, visibleLength } from '../terminal/text'
|
|
2
|
+
import type { ClickTarget, Selection } from './selection'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The lines of a frame, the line holding the selected row, a phase or a task (-1 when none), so the window can
|
|
6
|
+
* follow it, and the spans a click expands or closes.
|
|
7
|
+
*/
|
|
8
|
+
export type Frame = { lines: string[]; cursorLine: number; targets: ClickTarget[] }
|
|
9
|
+
|
|
10
|
+
/** What goes in front of a frame's lines: `first` on its first line, `rest` on the others, both drawn in `style`. */
|
|
11
|
+
export type Lead = { first: string; rest: string; style: (text: string) => string }
|
|
12
|
+
|
|
13
|
+
/** The tree lines: a branch to a sibling that has more below it, the last branch, and what carries on beside them. */
|
|
14
|
+
export const TREE = { branch: '├─ ', last: '└─ ', pipe: '│ ', gap: ' ', trunk: '│' } as const
|
|
15
|
+
const TREE_GUTTER = TREE.branch.length
|
|
16
|
+
|
|
17
|
+
export const textFrame = (lines: string[]): Frame => ({ lines, cursorLine: -1, targets: [] })
|
|
18
|
+
|
|
19
|
+
/** Stacks frames, moving each one's cursor line and click targets below the frames before it. */
|
|
20
|
+
export const joinFrames = (frames: Frame[]): Frame =>
|
|
21
|
+
frames.reduce<Frame>((joined, frame) => {
|
|
22
|
+
const offset = joined.lines.length
|
|
23
|
+
return {
|
|
24
|
+
lines: [...joined.lines, ...frame.lines],
|
|
25
|
+
cursorLine: frame.cursorLine === -1 ? joined.cursorLine : offset + frame.cursorLine,
|
|
26
|
+
targets: [...joined.targets, ...frame.targets.map((target) => ({ ...target, line: offset + target.line }))],
|
|
27
|
+
}
|
|
28
|
+
}, textFrame([]))
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A frame moved right by its lead, as many cells as `first` is wide; an empty line takes the lead without its
|
|
32
|
+
* trailing blanks. A click target over the whole line keeps the left edge and takes the lead in.
|
|
33
|
+
*/
|
|
34
|
+
export const prefixed = (frame: Frame, { first, rest, style }: Lead): Frame => {
|
|
35
|
+
const gutter = visibleLength(first)
|
|
36
|
+
return {
|
|
37
|
+
lines: frame.lines.map((line, index) => {
|
|
38
|
+
const lead = index === 0 ? first : rest
|
|
39
|
+
return line === '' ? style(lead.trimEnd()) : `${style(lead)}${line}`
|
|
40
|
+
}),
|
|
41
|
+
cursorLine: frame.cursorLine,
|
|
42
|
+
targets: frame.targets.map((target) => ({
|
|
43
|
+
...target,
|
|
44
|
+
from: target.wholeLine ? 0 : target.from + gutter,
|
|
45
|
+
to: target.to + gutter,
|
|
46
|
+
})),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The context of the children of a node: the pane minus the tree lines in front of them. */
|
|
51
|
+
export const deeper = <Context extends { width: number }>(context: Context): Context => ({
|
|
52
|
+
...context,
|
|
53
|
+
width: Math.max(context.width - TREE_GUTTER, 1),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const branches = (children: Frame[]): Frame[] => {
|
|
57
|
+
const present = children.filter((child) => child.lines.length > 0)
|
|
58
|
+
return present.map((child, index) => {
|
|
59
|
+
const last = index === present.length - 1
|
|
60
|
+
return prefixed(child, {
|
|
61
|
+
first: last ? TREE.last : TREE.branch,
|
|
62
|
+
rest: last ? TREE.gap : TREE.pipe,
|
|
63
|
+
style: paint.dim,
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The children of a node as a tree: each child's first line behind a branch, the last behind the closing one, the
|
|
70
|
+
* lines of a child after its first beside the trunk. The caller has drawn the children `TREE_GUTTER` narrower
|
|
71
|
+
* (`deeper`); a child without lines leaves no branch.
|
|
72
|
+
*/
|
|
73
|
+
export const nest = (children: Frame[]): Frame => joinFrames(branches(children))
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A row drawn inverse across the pane. A progress bar inside it is switched back to normal video: inverted,
|
|
77
|
+
* its filled and empty blocks would trade places with the background and the bar would be unreadable.
|
|
78
|
+
*/
|
|
79
|
+
const highlight = (line: string, width: number): string => {
|
|
80
|
+
const bars = line.match(BAR_SEGMENT) ?? []
|
|
81
|
+
const texts = line.split(BAR_SEGMENT).map(stripAnsi)
|
|
82
|
+
const body = texts.map((text, index) => `${text}${bars[index] ? `${bars[index]}${INVERSE_ON}` : ''}`).join('')
|
|
83
|
+
return `${INVERSE_ON}${body}${' '.repeat(Math.max(width - visibleLength(line), 0))}${RESET}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A selectable row: drawn inverse when selected, clickable across the pane on every line, `below` follows unselectable. */
|
|
87
|
+
export const rowFrame = (
|
|
88
|
+
key: string,
|
|
89
|
+
rows: string[],
|
|
90
|
+
below: string[],
|
|
91
|
+
{ state, width }: { state: Selection; width: number },
|
|
92
|
+
): Frame => {
|
|
93
|
+
const selected = state.selected === key
|
|
94
|
+
return {
|
|
95
|
+
lines: [...(selected ? rows.map((row) => highlight(row, width)) : rows), ...below],
|
|
96
|
+
cursorLine: selected ? 0 : -1,
|
|
97
|
+
targets: rows.map((_, line) => ({ line, from: 0, to: width, key, wholeLine: true as const })),
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/plans/git.ts
CHANGED
|
@@ -14,6 +14,14 @@ const git = (cwd: string, args: string[]): string | null => {
|
|
|
14
14
|
return result.exitCode === 0 ? result.stdout.toString().trim() : null
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** What git says about a file in a checkout: its porcelain status and the short commit that last changed it. */
|
|
18
|
+
export type FileGitFacts = { status: string | null; commit: string | null }
|
|
19
|
+
|
|
20
|
+
export const fileGitFacts = (root: string, path: string): FileGitFacts => ({
|
|
21
|
+
status: git(root, ['status', '--porcelain', '--', path]),
|
|
22
|
+
commit: git(root, ['log', '-1', '--format=%h', '--', path]),
|
|
23
|
+
})
|
|
24
|
+
|
|
17
25
|
/** The text of `path` at `ref`, or null when the ref or the file does not exist there. */
|
|
18
26
|
export const showAtRef = (root: string, ref: string, path: string): string | null => {
|
|
19
27
|
const result = Bun.spawnSync(['git', '-C', root, 'show', `${ref}:${path}`], { stdout: 'pipe', stderr: 'pipe' })
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { repositoryKey } from '../watch/watcher-lock'
|
|
6
|
+
import type { LaneMemory } from './current-phase'
|
|
7
|
+
|
|
8
|
+
const DIRECTORY_PREFIX = 'jackherd-herd'
|
|
9
|
+
const MEMORY_FILE = 'herd-memory.json'
|
|
10
|
+
const PRIVATE_DIRECTORY = 0o700
|
|
11
|
+
const PRIVATE_FILE = 0o600
|
|
12
|
+
|
|
13
|
+
const phaseRef = z.object({ planHref: z.string(), phaseId: z.string() })
|
|
14
|
+
|
|
15
|
+
const laneMemory = z.object({
|
|
16
|
+
ticks: z.record(z.string(), z.record(z.string(), z.string())),
|
|
17
|
+
moved: phaseRef.nullable(),
|
|
18
|
+
row: z.string(),
|
|
19
|
+
activity: z.record(z.string(), z.number()).optional(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const memoryFile = z.record(z.string(), laneMemory)
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The lanes' memories as a file holds them; nothing for a missing, broken or foreign file, so the herd then starts
|
|
26
|
+
* as on its first read and no phase gets a time from it.
|
|
27
|
+
*/
|
|
28
|
+
export const parseLaneMemories = (text: string | null): Map<string, LaneMemory> => {
|
|
29
|
+
if (text === null) return new Map()
|
|
30
|
+
try {
|
|
31
|
+
const parsed = memoryFile.safeParse(JSON.parse(text))
|
|
32
|
+
return parsed.success ? new Map(Object.entries(parsed.data)) : new Map()
|
|
33
|
+
} catch {
|
|
34
|
+
return new Map()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What a herd that read `lanes` writes back: the stored memories with those lanes replaced by its own, a lane it read
|
|
40
|
+
* but no longer remembers dropped; other herds' lanes (another pane, another `--lane`) stay as they were.
|
|
41
|
+
*/
|
|
42
|
+
export const mergedMemories = (
|
|
43
|
+
stored: ReadonlyMap<string, LaneMemory>,
|
|
44
|
+
own: ReadonlyMap<string, LaneMemory>,
|
|
45
|
+
lanes: readonly string[],
|
|
46
|
+
): Record<string, LaneMemory> => {
|
|
47
|
+
const read = new Set(lanes)
|
|
48
|
+
const kept = [...stored].filter(([lane]) => !read.has(lane))
|
|
49
|
+
const fresh = [...own].filter(([lane]) => read.has(lane))
|
|
50
|
+
return Object.fromEntries([...kept, ...fresh])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where the herds of the repository at `root` keep what they saw of each lane, one directory per user and
|
|
55
|
+
* repository like the watcher's, so a `--once` run and a restarted herd know when a phase last moved.
|
|
56
|
+
*/
|
|
57
|
+
const herdMemoryDirectory = (root: string): string =>
|
|
58
|
+
join(tmpdir(), `${DIRECTORY_PREFIX}-${process.getuid?.() ?? 'user'}`, repositoryKey(root))
|
|
59
|
+
|
|
60
|
+
export const memoryFileOf = (directory: string): string => join(directory, MEMORY_FILE)
|
|
61
|
+
|
|
62
|
+
const readText = (file: string): string | null => {
|
|
63
|
+
try {
|
|
64
|
+
return readFileSync(file, 'utf8')
|
|
65
|
+
} catch {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const readLaneMemories = (directory: string): Map<string, LaneMemory> =>
|
|
71
|
+
parseLaneMemories(readText(memoryFileOf(directory)))
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Writes the memories of `lanes` into the file of `directory`, beside what other herds keep there; readable by the
|
|
75
|
+
* user only, and replaced in one step so a herd reading at the same time never sees half a file.
|
|
76
|
+
*/
|
|
77
|
+
export const writeLaneMemories = (
|
|
78
|
+
directory: string,
|
|
79
|
+
memories: ReadonlyMap<string, LaneMemory>,
|
|
80
|
+
lanes: readonly string[],
|
|
81
|
+
): void => {
|
|
82
|
+
mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
|
|
83
|
+
const file = memoryFileOf(directory)
|
|
84
|
+
const merged = mergedMemories(readLaneMemories(directory), memories, lanes)
|
|
85
|
+
const partial = `${file}.${process.pid}`
|
|
86
|
+
writeFileSync(partial, JSON.stringify(merged), { mode: PRIVATE_FILE })
|
|
87
|
+
renameSync(partial, file)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The memories the herds of the repository at `root` kept. */
|
|
91
|
+
export const recallLanes = (root: string): Map<string, LaneMemory> => readLaneMemories(herdMemoryDirectory(root))
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Keeps the memories of `lanes` for the next herd. A memory that cannot be written costs only the times of the next
|
|
95
|
+
* run, which then starts as on its first read, so the view goes on without it.
|
|
96
|
+
*/
|
|
97
|
+
export const rememberLanes = (
|
|
98
|
+
root: string,
|
|
99
|
+
memories: ReadonlyMap<string, LaneMemory>,
|
|
100
|
+
lanes: readonly string[],
|
|
101
|
+
): void => {
|
|
102
|
+
try {
|
|
103
|
+
writeLaneMemories(herdMemoryDirectory(root), memories, lanes)
|
|
104
|
+
} catch {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/plans/lane-plans.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { type PlansByHref, parsePlanPhases, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
4
4
|
import type { BaseReader } from './git'
|
|
@@ -52,13 +52,14 @@ export const laneWorkOf = (worktree: string, status: { ahead: number | null; dir
|
|
|
52
52
|
* The plans of a lane. The base ref is the truth; while the lane has work of its own in its worktree, the phases
|
|
53
53
|
* its rows name, and those new there or ticked further, come from the worktree (`mergePlanPhases`). A plan the base ref lacks is read from the lane's worktree, else from the main
|
|
54
54
|
* checkout (a repository that ignores `docs/` keeps plan and board only there). `files` are the plan files to
|
|
55
|
-
* watch for changes.
|
|
55
|
+
* watch for changes, `modified` when each was last written, by plan link (`nextActivity` bounds a movement by it).
|
|
56
56
|
*/
|
|
57
57
|
export const readPlans = async (lane: SprintLane, sources: PlanSources, work: LaneWork, fallbacks: Fallbacks) => {
|
|
58
58
|
const { root, settings, sprintDirectory } = sources
|
|
59
59
|
const plans: PlansByHref = {}
|
|
60
60
|
const files: string[] = []
|
|
61
61
|
const texts: Record<string, string> = {}
|
|
62
|
+
const modified: Record<string, number> = {}
|
|
62
63
|
for (const href of new Set(lane.steps.flatMap((step) => (step.planHref ? [step.planHref] : [])))) {
|
|
63
64
|
const path = resolvePlanPath(sprintDirectory, href)
|
|
64
65
|
const worktreeFile = work.worktree ? join(work.worktree, path) : null
|
|
@@ -75,6 +76,7 @@ export const readPlans = async (lane: SprintLane, sources: PlanSources, work: La
|
|
|
75
76
|
)
|
|
76
77
|
files.push(file)
|
|
77
78
|
texts[href] = text
|
|
79
|
+
if (existsSync(file)) modified[href] = statSync(file).mtimeMs
|
|
78
80
|
}
|
|
79
|
-
return { plans, files, texts }
|
|
81
|
+
return { plans, files, texts, modified }
|
|
80
82
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPhaseFinished,
|
|
3
|
+
isPhaseOpen,
|
|
4
|
+
isTickedUnmerged,
|
|
5
|
+
type LaneProgress,
|
|
6
|
+
type PhaseProgress,
|
|
7
|
+
type PlanPhase,
|
|
8
|
+
type PlansByHref,
|
|
9
|
+
parseDependencyTable,
|
|
10
|
+
phaseProgress,
|
|
11
|
+
phaseWaves,
|
|
12
|
+
} from '../plan-format'
|
|
13
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
14
|
+
import { type ActivityTimes, activityKey } from './current-phase'
|
|
15
|
+
import { currentTasks, type TaskSignal } from './current-task'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A phase the lane works on now: an open one whose ticks or task lines moved less than `STALE_ACTIVITY_MINUTES` ago.
|
|
19
|
+
* `stepIndex` is the newest step of the lane that shows it, null for a phase no step shows; `minutesAgo` the whole
|
|
20
|
+
* minutes since that movement.
|
|
21
|
+
*/
|
|
22
|
+
export type ActivePhase = {
|
|
23
|
+
phase: PhaseProgress
|
|
24
|
+
stepIndex: number | null
|
|
25
|
+
minutesAgo: number
|
|
26
|
+
/** The index of the task the phase is on in its checkboxes (`currentTasks`); null when none is open. */
|
|
27
|
+
taskIndex: number | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The phases that come next: the open ones of the next wave, `wave` its 1-based number, null without a dependency table. */
|
|
31
|
+
export type NextPhases = { wave: number | null; phaseIds: string[] }
|
|
32
|
+
|
|
33
|
+
/** Open phases after the next ones; `planTitle` is null for the lane's current plan, the board's title for another. */
|
|
34
|
+
export type LaterPhases = { planTitle: string | null; phaseIds: string[] }
|
|
35
|
+
|
|
36
|
+
export type FinishedPhase = { id: string; unmerged: boolean }
|
|
37
|
+
|
|
38
|
+
/** A phase the lane's board rows name that has nothing to run: `missing` when the plan lacks it, else it has no checkbox. */
|
|
39
|
+
export type UnwrittenPhase = { id: string; missing: boolean }
|
|
40
|
+
|
|
41
|
+
/** Checkboxes and phases over every plan of the lane, a phase counted when it has a checkbox. */
|
|
42
|
+
export type PlanTally = { done: number; total: number; phasesDone: number; phases: number }
|
|
43
|
+
|
|
44
|
+
/** A lane as the herd sums it up: what runs now, what comes next and after, what is finished, and how far it is. */
|
|
45
|
+
export type LaneSummary = {
|
|
46
|
+
/** The running phases, the one moved last first. */
|
|
47
|
+
active: ActivePhase[]
|
|
48
|
+
/** The open phase of the current plan moved last, at any age; null while none has a time. */
|
|
49
|
+
latest: PhaseProgress | null
|
|
50
|
+
next: NextPhases | null
|
|
51
|
+
later: LaterPhases[]
|
|
52
|
+
finished: FinishedPhase[]
|
|
53
|
+
unwritten: UnwrittenPhase[]
|
|
54
|
+
tally: PlanTally
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `phases` are the lane's plans as it reads them (the base merged with its worktree), `texts` their texts for the
|
|
59
|
+
* dependency tables; a plan missing from `phases` falls back to the phases the lane's steps show.
|
|
60
|
+
*/
|
|
61
|
+
export type SummaryInput = {
|
|
62
|
+
progress: LaneProgress
|
|
63
|
+
phases: Readonly<PlansByHref>
|
|
64
|
+
texts: Readonly<Record<string, string>>
|
|
65
|
+
activity: ActivityTimes
|
|
66
|
+
/** The language the plans were written in, for reading their dependency tables; the herd's words are English. */
|
|
67
|
+
language: PlanLanguage
|
|
68
|
+
/** Hints at the task a phase is on, by phase id; none when absent, and no source writes one yet. */
|
|
69
|
+
signals?: readonly TaskSignal[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Minutes without a tick or a new task line after which a phase no longer runs. */
|
|
73
|
+
export const STALE_ACTIVITY_MINUTES = 20
|
|
74
|
+
|
|
75
|
+
const MINUTE = 60_000
|
|
76
|
+
|
|
77
|
+
const isWritten = ({ total }: PhaseProgress): boolean => total > 0
|
|
78
|
+
|
|
79
|
+
const planHrefs = ({ steps }: LaneProgress): string[] => [
|
|
80
|
+
...new Set(steps.flatMap(({ step }) => (step.planHref ? [step.planHref] : []))),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
/** The plan of the lane's current phase, else its first plan. */
|
|
84
|
+
const focusHref = (progress: LaneProgress): string | null => {
|
|
85
|
+
const current = progress.current ? progress.steps[progress.current.stepIndex]?.step.planHref : null
|
|
86
|
+
return current ?? planHrefs(progress)[0] ?? null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The phases the steps on `href` show, each once, when the plan itself was not read. */
|
|
90
|
+
const stepPhases = ({ steps }: LaneProgress, href: string): PhaseProgress[] => {
|
|
91
|
+
const shown = steps.filter(({ step }) => step.planHref === href).flatMap(({ phases }) => phases)
|
|
92
|
+
return shown.filter((phase, index) => shown.findIndex(({ id }) => id === phase.id) === index)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const phasesOf = (input: SummaryInput, href: string): PhaseProgress[] => {
|
|
96
|
+
const plan: readonly PlanPhase[] | undefined = input.phases[href]
|
|
97
|
+
return plan ? plan.map(({ id }) => phaseProgress(plan, id)) : stepPhases(input.progress, href)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const newestStepShowing = ({ steps }: LaneProgress, href: string, id: string): number | null => {
|
|
101
|
+
const index = steps
|
|
102
|
+
.map(({ step, phases }) => step.planHref === href && phases.some((phase) => phase.id === id))
|
|
103
|
+
.lastIndexOf(true)
|
|
104
|
+
return index === -1 ? null : index
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type TimedPhase = { phase: PhaseProgress; minutesAgo: number }
|
|
108
|
+
|
|
109
|
+
/** The open phases with a time of their last movement, the one moved last first; a phase without a time is absent. */
|
|
110
|
+
const timedPhases = (input: SummaryInput, href: string, phases: PhaseProgress[], now: number): TimedPhase[] =>
|
|
111
|
+
phases
|
|
112
|
+
.filter(isPhaseOpen)
|
|
113
|
+
.flatMap((phase) => {
|
|
114
|
+
const at = input.activity[activityKey(href, phase.id)]
|
|
115
|
+
return at === undefined ? [] : [{ phase, minutesAgo: Math.max(Math.floor((now - at) / MINUTE), 0) }]
|
|
116
|
+
})
|
|
117
|
+
.sort((first, second) => first.minutesAgo - second.minutesAgo)
|
|
118
|
+
|
|
119
|
+
/** The timed phases that moved less than `STALE_ACTIVITY_MINUTES` ago, each with the task it is on. */
|
|
120
|
+
const activeOf = (input: SummaryInput, href: string, timed: TimedPhase[], now: number): ActivePhase[] => {
|
|
121
|
+
const running = timed.filter(({ minutesAgo }) => minutesAgo < STALE_ACTIVITY_MINUTES)
|
|
122
|
+
const tasks = currentTasks(
|
|
123
|
+
running.map(({ phase }) => phase),
|
|
124
|
+
input.signals ?? [],
|
|
125
|
+
now,
|
|
126
|
+
STALE_ACTIVITY_MINUTES * MINUTE,
|
|
127
|
+
)
|
|
128
|
+
return running.map(({ phase, minutesAgo }) => ({
|
|
129
|
+
phase,
|
|
130
|
+
stepIndex: newestStepShowing(input.progress, href, phase.id),
|
|
131
|
+
minutesAgo,
|
|
132
|
+
taskIndex: tasks.get(phase.id) ?? null,
|
|
133
|
+
}))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The open phases of the first wave that holds one not running; without a dependency table, phases run one after the
|
|
138
|
+
* other and the next is the first open one not running. Null when nothing is left, or no wave holds what is left.
|
|
139
|
+
*/
|
|
140
|
+
const nextOf = (
|
|
141
|
+
input: SummaryInput,
|
|
142
|
+
href: string,
|
|
143
|
+
phases: PhaseProgress[],
|
|
144
|
+
running: ReadonlySet<string>,
|
|
145
|
+
): NextPhases | null => {
|
|
146
|
+
const waiting = phases.filter((phase) => isPhaseOpen(phase) && !running.has(phase.id)).map(({ id }) => id)
|
|
147
|
+
const text = input.texts[href]
|
|
148
|
+
const rows = text === undefined ? null : parseDependencyTable(text, input.language)
|
|
149
|
+
if (rows === null) return waiting.length > 0 ? { wave: null, phaseIds: [waiting[0]] } : null
|
|
150
|
+
const done = new Set(phases.filter(isPhaseFinished).map(({ id }) => id))
|
|
151
|
+
const waves = phaseWaves(rows, done)
|
|
152
|
+
const index = waves.findIndex((wave) => wave.some((id) => waiting.includes(id)))
|
|
153
|
+
return index === -1 ? null : { wave: index + 1, phaseIds: waves[index].filter((id) => waiting.includes(id)) }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const tallyOf = (phases: PhaseProgress[]): PlanTally => ({
|
|
157
|
+
done: phases.reduce((sum, phase) => sum + phase.done, 0),
|
|
158
|
+
total: phases.reduce((sum, phase) => sum + phase.total, 0),
|
|
159
|
+
phasesDone: phases.filter(isPhaseFinished).length,
|
|
160
|
+
phases: phases.filter(isWritten).length,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const titleOf = ({ steps }: LaneProgress, href: string): string =>
|
|
164
|
+
steps.find(({ step }) => step.planHref === href)?.step.planTitle ?? href
|
|
165
|
+
|
|
166
|
+
/** The open phases of the lane's other plans, each plan by its title. */
|
|
167
|
+
const otherPlans = (input: SummaryInput, hrefs: string[]): LaterPhases[] =>
|
|
168
|
+
hrefs.flatMap((href) => {
|
|
169
|
+
const open = phasesOf(input, href).filter(isPhaseOpen)
|
|
170
|
+
return open.length > 0 ? [{ planTitle: titleOf(input.progress, href), phaseIds: open.map(({ id }) => id) }] : []
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
/** The open phases of the current plan that neither run nor come next, then the open phases of the lane's other plans. */
|
|
174
|
+
const laterOf = (
|
|
175
|
+
input: SummaryInput,
|
|
176
|
+
href: string,
|
|
177
|
+
phases: PhaseProgress[],
|
|
178
|
+
upcoming: ReadonlySet<string>,
|
|
179
|
+
): LaterPhases[] => {
|
|
180
|
+
const waiting = phases
|
|
181
|
+
.filter((phase) => isPhaseOpen(phase) && !upcoming.has(phase.id))
|
|
182
|
+
.map(({ id }) => id)
|
|
183
|
+
.sort((first, second) => first.localeCompare(second, undefined, { numeric: true }))
|
|
184
|
+
const others = otherPlans(
|
|
185
|
+
input,
|
|
186
|
+
planHrefs(input.progress).filter((each) => each !== href),
|
|
187
|
+
)
|
|
188
|
+
return [...(waiting.length > 0 ? [{ planTitle: null, phaseIds: waiting }] : []), ...others]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The finished phases of the current plan, each marked while its ticks are not merged. */
|
|
192
|
+
const finishedOf = (phases: PhaseProgress[]): FinishedPhase[] =>
|
|
193
|
+
phases.filter(isPhaseFinished).map((phase) => ({ id: phase.id, unmerged: isTickedUnmerged(phase) }))
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The lane summed up over its plans at `now`: the open phases running in its current plan (moved less than
|
|
197
|
+
* `STALE_ACTIVITY_MINUTES` ago; a phase without a time of its last movement never runs), the open phase moved last,
|
|
198
|
+
* the next wave's open phases, the other open phases, the finished ones (marked when not merged yet), the phases its
|
|
199
|
+
* rows name with nothing to run, and the tally over every plan of the lane. A phase ticked to the end, review gate
|
|
200
|
+
* included, is finished, never running.
|
|
201
|
+
*/
|
|
202
|
+
export const laneSummary = (input: SummaryInput, now: number): LaneSummary => {
|
|
203
|
+
const href = focusHref(input.progress)
|
|
204
|
+
const tally = tallyOf(planHrefs(input.progress).flatMap((each) => phasesOf(input, each)))
|
|
205
|
+
if (href === null) return { active: [], latest: null, next: null, later: [], finished: [], unwritten: [], tally }
|
|
206
|
+
const phases = phasesOf(input, href)
|
|
207
|
+
const timed = timedPhases(input, href, phases, now)
|
|
208
|
+
const active = activeOf(input, href, timed, now)
|
|
209
|
+
const running = new Set(active.map(({ phase }) => phase.id))
|
|
210
|
+
const next = nextOf(input, href, phases, running)
|
|
211
|
+
return {
|
|
212
|
+
active,
|
|
213
|
+
latest: timed[0]?.phase ?? null,
|
|
214
|
+
next,
|
|
215
|
+
later: laterOf(input, href, phases, new Set([...running, ...(next?.phaseIds ?? [])])),
|
|
216
|
+
finished: finishedOf(phases),
|
|
217
|
+
unwritten: stepPhases(input.progress, href)
|
|
218
|
+
.filter((phase) => !isWritten(phase))
|
|
219
|
+
.map(({ id, title }) => ({ id, missing: title === null })),
|
|
220
|
+
tally,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { ChecklistItem, DeferredItem, LaneProgress, PhaseProgress } from '../plan-format'
|
|
2
|
+
import { onlyDocumentationOpen, phaseLabel } from '../plan-format'
|
|
3
|
+
import { fit, padVisible, paint, wrap } from '../terminal/text'
|
|
4
|
+
import { textWidth } from '../terminal/width'
|
|
5
|
+
import type { WatchBadges } from '../watch/plan-badges'
|
|
6
|
+
import { type Frame, rowFrame, textFrame } from './frame'
|
|
7
|
+
import type { LaneTickets } from './lane-tickets'
|
|
8
|
+
import type { LinkChip } from './link-chips'
|
|
9
|
+
import type { PhaseModel } from './phase-models'
|
|
10
|
+
import { itemKey, type Selection, tickedKey } from './selection'
|
|
11
|
+
import { chevronOf, MODEL_MARK, PHASE_COUNT, PHASE_STATE, type PhaseState, phaseStateOf } from './symbols'
|
|
12
|
+
import { type BadgeTexts, TEXTS } from './texts'
|
|
13
|
+
|
|
14
|
+
/** What every block of one lane is drawn with. */
|
|
15
|
+
export type LaneContext = {
|
|
16
|
+
progress: LaneProgress
|
|
17
|
+
state: Selection
|
|
18
|
+
width: number
|
|
19
|
+
chips: Record<string, LinkChip[]>
|
|
20
|
+
badges: WatchBadges
|
|
21
|
+
badgeTexts: BadgeTexts
|
|
22
|
+
/** The lane's tickets for the plan block that shows them, the first; null in every other block. */
|
|
23
|
+
tickets: LaneTickets | null
|
|
24
|
+
/** The model each phase ran on, by `phaseModelKey`. */
|
|
25
|
+
models: Record<string, PhaseModel>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ACTIVE_MARK = '▶'
|
|
29
|
+
export const DEFERRED_MARK = '→'
|
|
30
|
+
export const COLUMN_SEPARATOR = ' '
|
|
31
|
+
export const MIN_TITLE_WIDTH = 8
|
|
32
|
+
|
|
33
|
+
/** Places on each side of the slash of a count: ` 9/9 `, ` 15/15 `, `123/456`. */
|
|
34
|
+
const COUNT_PLACES = 3
|
|
35
|
+
/** The cells before a count: `◐`, `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
|
|
36
|
+
const COUNT_MARK_WIDTH = 2
|
|
37
|
+
const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
|
|
38
|
+
const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
|
|
39
|
+
const MARKDOWN_MARKS = /\*\*|`/g
|
|
40
|
+
|
|
41
|
+
const PHASE_PAINT: Record<PhaseState, (count: string) => string> = {
|
|
42
|
+
[PHASE_STATE.missing]: paint.dim,
|
|
43
|
+
[PHASE_STATE.empty]: paint.dim,
|
|
44
|
+
[PHASE_STATE.all]: paint.green,
|
|
45
|
+
[PHASE_STATE.open]: (count) => count,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PHASE_MARK: Record<PhaseState, string> = {
|
|
49
|
+
[PHASE_STATE.missing]: PHASE_COUNT.missing,
|
|
50
|
+
[PHASE_STATE.empty]: PHASE_COUNT.empty,
|
|
51
|
+
[PHASE_STATE.all]: '',
|
|
52
|
+
[PHASE_STATE.open]: '',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const plain = (text: string): string => text.replace(MARKDOWN_MARKS, '')
|
|
56
|
+
|
|
57
|
+
export const countText = (done: number, total: number): string =>
|
|
58
|
+
`${String(done).padStart(COUNT_PLACES)}/${String(total).padEnd(COUNT_PLACES)}`
|
|
59
|
+
|
|
60
|
+
/** Mark and count in one fixed-width cell at the right edge, the same for the lane, phase and planned rows. */
|
|
61
|
+
export const countCell = (mark: string, count: string): string => `${padVisible(mark, COUNT_MARK_WIDTH)} ${count}`
|
|
62
|
+
|
|
63
|
+
/** A word in place of a count, right-aligned in the width of the count cell like `n/m`. */
|
|
64
|
+
const wordCell = (word: string): string => `${' '.repeat(Math.max(COUNT_CELL_WIDTH - textWidth(word), 0))}${word}`
|
|
65
|
+
|
|
66
|
+
/** The count cell of a phase row at the right edge; the current phase is bold yellow. A phase lacking only the documentation box says so instead of a count. */
|
|
67
|
+
export const phaseCountCell = (phase: PhaseProgress, current: boolean): string => {
|
|
68
|
+
const state = phaseStateOf(phase)
|
|
69
|
+
if (state === PHASE_STATE.open && onlyDocumentationOpen(phase)) {
|
|
70
|
+
const cell = wordCell(TEXTS.allButDocs)
|
|
71
|
+
return current ? paint.bold(paint.yellow(cell)) : paint.green(cell)
|
|
72
|
+
}
|
|
73
|
+
const hasCount = state === PHASE_STATE.open || state === PHASE_STATE.all
|
|
74
|
+
const cell = countCell(PHASE_MARK[state], hasCount ? countText(phase.done, phase.total) : BLANK_COUNT)
|
|
75
|
+
return current ? paint.bold(paint.yellow(cell)) : PHASE_PAINT[state](cell)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const familyLabel = (family: string): string => `${family.charAt(0).toUpperCase()}${family.slice(1)}`
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The model the phase's subagent last ran on, only where it differs from the plan's recommendation: `Sonnet ≠ Opus`
|
|
82
|
+
* in amber; empty without a record, without a recommendation, or when it fits.
|
|
83
|
+
*/
|
|
84
|
+
export const modelMark = (model: PhaseModel | undefined): string =>
|
|
85
|
+
model?.recommended && model.fits === false
|
|
86
|
+
? paint.yellow(`${familyLabel(model.used)} ${MODEL_MARK.differs} ${familyLabel(model.recommended)}`)
|
|
87
|
+
: ''
|
|
88
|
+
|
|
89
|
+
/** `text` wrapped beside a one-cell `mark`, each line in `style`; follow-up lines indent under the text. */
|
|
90
|
+
const markedLines = (text: string, mark: string, style: (line: string) => string, width: number): string[] =>
|
|
91
|
+
wrap(plain(text), Math.max(width - 2, 1)).map((line, index) => `${index > 0 ? ' ' : mark} ${style(line)}`)
|
|
92
|
+
|
|
93
|
+
/** The mark and the text style of a task: a ticked one green and dim, the one being worked on yellow and bold. */
|
|
94
|
+
const taskLook = ({ done }: ChecklistItem, active: boolean): { mark: string; style: (line: string) => string } => {
|
|
95
|
+
if (active) return { mark: paint.yellow(ACTIVE_MARK), style: paint.bold }
|
|
96
|
+
return done ? { mark: paint.green('☑'), style: paint.dim } : { mark: '☐', style: (line) => line }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A closed task as one row, cut at the pane's edge with `…`. */
|
|
100
|
+
const closedTaskRow = (item: ChecklistItem, active: boolean, width: number): string[] => {
|
|
101
|
+
const { mark, style } = taskLook(item, active)
|
|
102
|
+
return [fit(`${mark} ${style(plain(item.text))}`, width)]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** An open task as rows: its whole text wrapped. */
|
|
106
|
+
const openTaskRows = (item: ChecklistItem, active: boolean, width: number): string[] => {
|
|
107
|
+
const { mark, style } = taskLook(item, active)
|
|
108
|
+
return markedLines(item.text, mark, style, width)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A task as a selectable row under its `key`: clickable across every line it takes, the cursor on its first. */
|
|
112
|
+
export const taskFrame = (key: string, rows: string[], context: Pick<LaneContext, 'state' | 'width'>): Frame =>
|
|
113
|
+
rowFrame(key, rows, [], context)
|
|
114
|
+
|
|
115
|
+
/** A checkbox moved to another phase, dim: `→ P9 <text>`. */
|
|
116
|
+
const deferredRow = ({ text, target }: DeferredItem, width: number): string =>
|
|
117
|
+
fit(paint.dim(`${DEFERRED_MARK} ${phaseLabel(target)} ${plain(text)}`), width)
|
|
118
|
+
|
|
119
|
+
const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number): Frame => ({
|
|
120
|
+
lines: [fit(paint.dim(`${chevronOf(false)} ${TEXTS.tickedSummary(count)}`), width)],
|
|
121
|
+
cursorLine: -1,
|
|
122
|
+
targets: [{ line: 0, from: 0, to: width, key: tickedKey(phaseRowKey), wholeLine: true }],
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The children of an open phase, one frame per task: open ones first, `active` marked among them, then the ones
|
|
127
|
+
* deferred to another phase, then the ticked ones listed or summed up.
|
|
128
|
+
*/
|
|
129
|
+
export const phaseChildren = (
|
|
130
|
+
context: LaneContext,
|
|
131
|
+
phaseRowKey: string,
|
|
132
|
+
phase: PhaseProgress,
|
|
133
|
+
active: ChecklistItem | null,
|
|
134
|
+
): Frame[] => {
|
|
135
|
+
const { state, width } = context
|
|
136
|
+
const frameOf = (item: ChecklistItem, index: number): Frame => {
|
|
137
|
+
const key = itemKey(phaseRowKey, index)
|
|
138
|
+
const isActive = item === active
|
|
139
|
+
const rows = state.expanded.has(key) ? openTaskRows(item, isActive, width) : closedTaskRow(item, isActive, width)
|
|
140
|
+
return taskFrame(key, rows, context)
|
|
141
|
+
}
|
|
142
|
+
const framesWhere = (done: boolean): Frame[] =>
|
|
143
|
+
phase.items.flatMap((item, index) => (item.done === done ? [frameOf(item, index)] : []))
|
|
144
|
+
const tickedCount = phase.items.filter((item) => item.done).length
|
|
145
|
+
const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width)] : []
|
|
146
|
+
const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, width)]))
|
|
147
|
+
return [...framesWhere(false), ...deferred, ...(state.showDone ? framesWhere(true) : summary)]
|
|
148
|
+
}
|