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
package/src/plans/live.ts
CHANGED
|
@@ -16,8 +16,10 @@ import {
|
|
|
16
16
|
} from './fetch'
|
|
17
17
|
import { fetchHeadPath } from './git'
|
|
18
18
|
import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
|
|
19
|
+
import { recallLanes, rememberLanes } from './herd-memory'
|
|
19
20
|
import type { Screen } from './layout'
|
|
20
21
|
import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
|
|
22
|
+
import { herdLabel } from './plan-panes'
|
|
21
23
|
import { pullRequestUrl } from './pull-requests'
|
|
22
24
|
import {
|
|
23
25
|
inputOutcome,
|
|
@@ -29,7 +31,7 @@ import {
|
|
|
29
31
|
type View,
|
|
30
32
|
windowTop,
|
|
31
33
|
} from './selection'
|
|
32
|
-
import { BASE_REMOTE, baseRefOf, boardRepoPathOf,
|
|
34
|
+
import { BASE_REMOTE, baseRefOf, boardRepoPathOf, CLOCK_LOCALE } from './settings'
|
|
33
35
|
import { singleFlight } from './single-flight'
|
|
34
36
|
import { loadSnapshot, type Snapshot, type Source } from './snapshot'
|
|
35
37
|
import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
|
|
@@ -51,7 +53,7 @@ type Live = {
|
|
|
51
53
|
/** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
|
|
52
54
|
fetchHead: string | null
|
|
53
55
|
reported: Map<string, string | null>
|
|
54
|
-
/** Per lane, the ticks seen at the last read
|
|
56
|
+
/** Per lane, the ticks seen at the last read, the phase that moved last and when each moved; kept across herds (`rememberLanes`). */
|
|
55
57
|
memories: Map<string, LaneMemory>
|
|
56
58
|
pending: Timer | null
|
|
57
59
|
/** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
|
|
@@ -133,7 +135,7 @@ const redraw = (live: Live) => {
|
|
|
133
135
|
live.view.selection,
|
|
134
136
|
[...live.errors, ...(live.pageError ? [live.pageError] : []), ...(live.notice ? [live.notice] : [])],
|
|
135
137
|
paneWidth(),
|
|
136
|
-
|
|
138
|
+
CLOCK_LOCALE,
|
|
137
139
|
)
|
|
138
140
|
const top = windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen))
|
|
139
141
|
live.screen = screen
|
|
@@ -189,6 +191,7 @@ const refresh = (live: Live, onChange: () => void): Promise<void> => {
|
|
|
189
191
|
const readOnce = async (live: Live, onChange: () => void) => {
|
|
190
192
|
try {
|
|
191
193
|
const read = await loadSnapshot(live.source, live.pullRequests, live.memories)
|
|
194
|
+
rememberLanes(live.source.root, live.memories, read.documents.laneOrder)
|
|
192
195
|
const next = withKnownPage(live, read)
|
|
193
196
|
live.read = read
|
|
194
197
|
if (!live.snapshot) live.view = { ...live.view, selection: startSelection(next, live.source.options) }
|
|
@@ -198,7 +201,7 @@ const readOnce = async (live: Live, onChange: () => void) => {
|
|
|
198
201
|
)
|
|
199
202
|
live.view = { ...live.view, selection: folded }
|
|
200
203
|
live.snapshot = next
|
|
201
|
-
const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings),
|
|
204
|
+
const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), CLOCK_LOCALE)
|
|
202
205
|
live.errors = [
|
|
203
206
|
...next.errors,
|
|
204
207
|
...(fetchLine ? [fetchLine] : []),
|
|
@@ -206,7 +209,7 @@ const readOnce = async (live: Live, onChange: () => void) => {
|
|
|
206
209
|
]
|
|
207
210
|
syncWatchers(live.watchers, watchedFiles(next.files, live.fetchHead), onChange)
|
|
208
211
|
} catch (error) {
|
|
209
|
-
const at = clockOf(
|
|
212
|
+
const at = clockOf(CLOCK_LOCALE)
|
|
210
213
|
live.errors = [`refresh failed at ${at}, showing the last good state: ${errorText(error)}`]
|
|
211
214
|
}
|
|
212
215
|
redraw(live)
|
|
@@ -280,7 +283,7 @@ export const runLive = (source: Source): void => {
|
|
|
280
283
|
watchers: new Map(),
|
|
281
284
|
fetchHead: fetchHeadPath(source.root),
|
|
282
285
|
reported: new Map(),
|
|
283
|
-
memories:
|
|
286
|
+
memories: recallLanes(source.root),
|
|
284
287
|
pending: null,
|
|
285
288
|
read: null,
|
|
286
289
|
page: null,
|
|
@@ -323,12 +326,14 @@ export const runLive = (source: Source): void => {
|
|
|
323
326
|
}
|
|
324
327
|
const quit = () => {
|
|
325
328
|
clearTitles(source, live.reported)
|
|
329
|
+
source.terminal.labelOwnPane(null)
|
|
326
330
|
for (const watcher of live.watchers.values()) watcher.close()
|
|
327
331
|
process.stdout.write(TERMINAL_OFF)
|
|
328
332
|
process.exit(0)
|
|
329
333
|
}
|
|
330
334
|
const onInput = (input: string) => (QUIT_KEYS.has(input) ? quit() : handleInput(live, input))
|
|
331
335
|
|
|
336
|
+
source.terminal.labelOwnPane(herdLabel(source.options.lane))
|
|
332
337
|
enterTerminal(onInput, quit, () => redraw(live))
|
|
333
338
|
setInterval(() => {
|
|
334
339
|
fetchWhenDue(false)
|
package/src/plans/plan-panes.ts
CHANGED
|
@@ -135,6 +135,21 @@ const quoted = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'
|
|
|
135
135
|
export const viewCommandOf = (argv: readonly string[], lane: string | null): string =>
|
|
136
136
|
[...argv.slice(0, 2), HERD_COMMAND.name, ...(lane ? ['--lane', lane] : [])].map(quoted).join(' ')
|
|
137
137
|
|
|
138
|
+
/** The pane label of a herd: `herd` for every lane, `herd · <lane>` for one lane. */
|
|
139
|
+
export const herdLabel = (lane: string | null): string => (lane ? `${HERD_COMMAND.name} · ${lane}` : HERD_COMMAND.name)
|
|
140
|
+
|
|
141
|
+
/** The pane label of the coordinator. */
|
|
142
|
+
export const COORDINATOR_LABEL = '🐾 Jack'
|
|
143
|
+
|
|
144
|
+
/** The coordinator's pane with its label, null when there is no coordinator pane among `panes`. */
|
|
145
|
+
export const coordinatorLabelOf = (
|
|
146
|
+
panes: readonly PaneState[],
|
|
147
|
+
coordinator: string | null,
|
|
148
|
+
): { pane: string; label: string } | null =>
|
|
149
|
+
coordinator && panes.some((each) => each.pane === coordinator)
|
|
150
|
+
? { pane: coordinator, label: COORDINATOR_LABEL }
|
|
151
|
+
: null
|
|
152
|
+
|
|
138
153
|
/** The new pane of a `herdr pane split` answer; throws on a changed answer. */
|
|
139
154
|
export const splitPaneOf = (json: string): string => {
|
|
140
155
|
const pane = (JSON.parse(json) as { result?: { pane?: { pane_id?: unknown } } }).result?.pane?.pane_id
|
package/src/plans/planned.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { posix } from 'node:path'
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
isPhaseOpen,
|
|
4
4
|
type PlanPhase,
|
|
5
5
|
parsePlanPhases,
|
|
6
|
+
phaseLabels,
|
|
6
7
|
readPlanHeader,
|
|
7
8
|
resolvePlanPath,
|
|
8
9
|
type SprintLane,
|
|
@@ -31,16 +32,10 @@ export type PlannedInput = {
|
|
|
31
32
|
language: PlanLanguage
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
* the gate does not reopen a phase that is built and merged. A phase that has only gate boxes counts by them.
|
|
37
|
-
*/
|
|
38
|
-
const isOpen = ({ items }: PlanPhase): boolean => {
|
|
39
|
-
const tasks = items.filter(({ gate }) => !gate)
|
|
40
|
-
return (tasks.length > 0 ? tasks : items).some(({ done }) => !done)
|
|
41
|
-
}
|
|
35
|
+
/** A plan's file, its text and its phases, as one plan row reads them. */
|
|
36
|
+
type PlanRead = { path: string; markdown: string; phases: readonly PlanPhase[] }
|
|
42
37
|
|
|
43
|
-
const openPhases = (phases: PlanPhase[]): PlanPhase[] => phases.filter(
|
|
38
|
+
const openPhases = (phases: readonly PlanPhase[]): PlanPhase[] => phases.filter(isPhaseOpen)
|
|
44
39
|
|
|
45
40
|
const isRunning = ({ lane, agent }: PlannedLane): boolean => agent !== null && !lane.closed
|
|
46
41
|
|
|
@@ -51,8 +46,8 @@ const planPaths = ({ lane }: PlannedLane, sprintDirectory: string): string[] =>
|
|
|
51
46
|
const followedPaths = ({ lanes, sprintDirectory }: PlannedInput): Set<string> =>
|
|
52
47
|
new Set(lanes.filter(isRunning).flatMap((lane) => planPaths(lane, sprintDirectory)))
|
|
53
48
|
|
|
54
|
-
const plannedPlanOf = (path
|
|
55
|
-
const phases = openPhases(
|
|
49
|
+
const plannedPlanOf = ({ path, markdown, phases: all }: PlanRead, language: PlanLanguage) => {
|
|
50
|
+
const phases = openPhases(all)
|
|
56
51
|
if (phases.length === 0) return []
|
|
57
52
|
const items = phases.flatMap((phase) => phase.items)
|
|
58
53
|
const plan: PlannedPlan = {
|
|
@@ -77,40 +72,12 @@ export const plannedPlans = (input: PlannedInput): PlannedPlan[] => {
|
|
|
77
72
|
const path = resolvePlanPath(input.sprintDirectory, href)
|
|
78
73
|
if (seen.has(path) || followed.has(path)) return []
|
|
79
74
|
seen.add(path)
|
|
80
|
-
return plannedPlanOf(path, markdown, input.language,
|
|
75
|
+
return plannedPlanOf({ path, markdown, phases: parsePlanPhases(markdown, input.language) }, input.language)
|
|
81
76
|
})
|
|
82
77
|
}
|
|
83
78
|
|
|
84
|
-
/**
|
|
85
|
-
* The open phases of the plans a lane works on that its own steps do not name, which is what comes after the
|
|
86
|
-
* phase the lane is on. Empty for a lane without an agent or a closed one: it is listed in the closing block.
|
|
87
|
-
*/
|
|
88
|
-
export const followingPlans = (planned: PlannedLane, input: Omit<PlannedInput, 'lanes'>): PlannedPlan[] => {
|
|
89
|
-
if (!isRunning(planned)) return []
|
|
90
|
-
const seen = new Set<string>()
|
|
91
|
-
return planned.lane.steps.flatMap(({ planHref }) => {
|
|
92
|
-
const markdown = planHref ? input.plans[planHref] : undefined
|
|
93
|
-
const path = planHref ? resolvePlanPath(input.sprintDirectory, planHref) : null
|
|
94
|
-
if (path === null || markdown === undefined || seen.has(path)) return []
|
|
95
|
-
seen.add(path)
|
|
96
|
-
const named = new Set(
|
|
97
|
-
planned.lane.steps
|
|
98
|
-
.filter((step) => step.planHref !== null && resolvePlanPath(input.sprintDirectory, step.planHref) === path)
|
|
99
|
-
.flatMap((step) => step.phaseIds),
|
|
100
|
-
)
|
|
101
|
-
return plannedPlanOf(path, markdown, input.language, named)
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** The plan the lane's current phase belongs to goes first; the others keep their order. */
|
|
106
|
-
export const currentPlanFirst = (plans: readonly PlannedPlan[], currentPath: string | null): PlannedPlan[] => [
|
|
107
|
-
...plans.filter(({ path }) => path === currentPath),
|
|
108
|
-
...plans.filter(({ path }) => path !== currentPath),
|
|
109
|
-
]
|
|
110
|
-
|
|
111
79
|
/** `P3 P4 P5` */
|
|
112
|
-
export const phaseList = (plan: Pick<PlannedPlan, 'phaseIds'>): string =>
|
|
113
|
-
plan.phaseIds.map((id) => `${PHASE_ID_PREFIX}${id}`).join(' ')
|
|
80
|
+
export const phaseList = (plan: Pick<PlannedPlan, 'phaseIds'>): string => phaseLabels(plan.phaseIds, ' ')
|
|
114
81
|
|
|
115
82
|
/** How many phases the plans list together. */
|
|
116
83
|
export const plannedPhaseCount = (planned: readonly Pick<PlannedPlan, 'phaseIds'>[]): number =>
|
package/src/plans/selection.ts
CHANGED
|
@@ -36,8 +36,8 @@ export type InputContext = {
|
|
|
36
36
|
bodyRows: number
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/** A clickable span of a frame line, columns 0-based and `to` exclusive. */
|
|
40
|
-
export type ClickTarget = { line: number; from: number; to: number; key: string }
|
|
39
|
+
/** A clickable span of a frame line, columns 0-based and `to` exclusive; `wholeLine` spans the pane whatever leads it. */
|
|
40
|
+
export type ClickTarget = { line: number; from: number; to: number; key: string; wholeLine?: true }
|
|
41
41
|
|
|
42
42
|
/** An SGR mouse report; column and row are 1-based as the terminal sends them. */
|
|
43
43
|
export type MouseEvent = { button: number; column: number; row: number; pressed: boolean }
|
|
@@ -71,12 +71,8 @@ export const PLANNED_KEY = 'planned'
|
|
|
71
71
|
|
|
72
72
|
export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
|
|
73
73
|
|
|
74
|
-
const FOLLOWING_KEY_PREFIX = 'following>'
|
|
75
74
|
const TICKED_KEY_PREFIX = 'ticked>'
|
|
76
75
|
|
|
77
|
-
/** The key of the folded row under a lane that lists the open phases after its own. */
|
|
78
|
-
export const followingKey = (lane: string): string => `${FOLLOWING_KEY_PREFIX}${lane}`
|
|
79
|
-
|
|
80
76
|
/** The key of the "N ticked" line of an open phase; it is clicked only, and a click lands on the phase. */
|
|
81
77
|
export const tickedKey = (phase: string): string => `${TICKED_KEY_PREFIX}${phase}`
|
|
82
78
|
|
package/src/plans/settings.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { join, relative } from 'node:path'
|
|
|
3
3
|
import { type JackherdConfig, parseJackherdConfig } from '../config'
|
|
4
4
|
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
5
5
|
import { readConfig } from '../config/read-config'
|
|
6
|
-
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
7
6
|
import type { TicketSource } from '../watch/plan-badges'
|
|
8
7
|
|
|
9
8
|
export const CONFIG_PATH = PROJECT_CONFIG_PATH
|
|
@@ -20,10 +19,8 @@ export type PlansSettings = Pick<
|
|
|
20
19
|
ticketSources: TicketSource[]
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
[PLAN_LANGUAGE.en]: 'en-GB',
|
|
26
|
-
}
|
|
22
|
+
/** The locale of the herd's clock: the herd is English, with a 24-hour clock. */
|
|
23
|
+
export const CLOCK_LOCALE = 'en-GB'
|
|
27
24
|
|
|
28
25
|
/** The herd's slice of a validated config. */
|
|
29
26
|
export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
@@ -67,8 +64,6 @@ export const BASE_REMOTE = 'origin'
|
|
|
67
64
|
|
|
68
65
|
export const baseRefOf = ({ baseBranch }: Pick<PlansSettings, 'baseBranch'>): string => `${BASE_REMOTE}/${baseBranch}`
|
|
69
66
|
|
|
70
|
-
export const clockLocaleOf = (settings: PlansSettings): string => CLOCK_LOCALE[settings.responseLanguage]
|
|
71
|
-
|
|
72
67
|
/** The herd's settings from the config `readConfig` finds. */
|
|
73
68
|
export const readSettings = (roots: string[], home: string = homedir()): PlansSettings =>
|
|
74
69
|
plansSettingsOf(readConfig(roots, home))
|
package/src/plans/snapshot.ts
CHANGED
|
@@ -14,22 +14,30 @@ import {
|
|
|
14
14
|
sprintTitleOf,
|
|
15
15
|
} from '../plan-format'
|
|
16
16
|
import type { LaneAgent, LaneStatusSource } from '../terminal'
|
|
17
|
-
import type { PlanLanguage } from '../vocabulary'
|
|
18
17
|
import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
|
|
19
18
|
import { readWatchReading } from '../watch/plan-badges-read'
|
|
20
19
|
import { boardRowsOf } from './board-rows'
|
|
21
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
type ActivityTimes,
|
|
22
|
+
type LaneMemory,
|
|
23
|
+
newestRowIdentity,
|
|
24
|
+
nextActivity,
|
|
25
|
+
nextMemory,
|
|
26
|
+
type PlanModified,
|
|
27
|
+
withMovedPhase,
|
|
28
|
+
} from './current-phase'
|
|
22
29
|
import { errorText } from './error-text'
|
|
23
30
|
import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
|
|
24
31
|
import type { PullRequestState } from './github'
|
|
25
32
|
import { type Fallbacks, laneWorkOf, readAtBase, readFallback, readPlans, textAtBase } from './lane-plans'
|
|
33
|
+
import { laneSummary } from './lane-summary'
|
|
26
34
|
import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
|
|
27
35
|
import { type LaneView, sortByUrgency } from './layout'
|
|
28
36
|
import { extractLinkChips } from './link-chips'
|
|
29
37
|
import type { Options } from './options'
|
|
30
38
|
import { laneModelsOf, type PhaseModelRecord } from './phase-models'
|
|
31
39
|
import { readPhaseModels } from './phase-models-read'
|
|
32
|
-
import {
|
|
40
|
+
import { type PlannedPlan, plannedPlans } from './planned'
|
|
33
41
|
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
|
|
34
42
|
import { boardTexts } from './texts'
|
|
35
43
|
import { type UnlistedLane, unlistedLanes, unlistedSprintLane } from './unlisted-lanes'
|
|
@@ -44,8 +52,6 @@ export type Source = {
|
|
|
44
52
|
terminal: LaneStatusSource
|
|
45
53
|
/** The log lines of an agent's chat, null without one; defaults to reading them from `~/.claude/projects`. */
|
|
46
54
|
readAgentLog?: AgentLogReader
|
|
47
|
-
/** Why this board was chosen over others, shown with the errors; null or absent when there was no choice. */
|
|
48
|
-
boardNotice?: string | null
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
/**
|
|
@@ -80,8 +86,6 @@ export type Snapshot = {
|
|
|
80
86
|
watch?: WatchHealth
|
|
81
87
|
/** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
|
|
82
88
|
watchesTickets?: boolean
|
|
83
|
-
/** The project's response language, for the words of the watcher's badges. */
|
|
84
|
-
language?: PlanLanguage
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
type LaneSources = {
|
|
@@ -104,12 +108,6 @@ const contextOf = (agent: LaneAgent | undefined, source: Source): ContextReading
|
|
|
104
108
|
return report && { share: report.share, level: report.level }
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
/** The repository path of the plan the lane's current phase belongs to; null when every phase is done. */
|
|
108
|
-
const currentPlanPath = ({ current, steps }: LaneProgress, sprintDirectory: string): string | null => {
|
|
109
|
-
const href = current ? steps[current.stepIndex].step.planHref : null
|
|
110
|
-
return href ? resolvePlanPath(sprintDirectory, href) : null
|
|
111
|
-
}
|
|
112
|
-
|
|
113
111
|
/** What a lane's worktree and agent say, listed in the sprint or not: git state, pull requests, context. */
|
|
114
112
|
const runningState = async (sources: LaneSources, name: string, agent: LaneAgent | undefined) => {
|
|
115
113
|
const { source, pullRequests } = sources
|
|
@@ -118,6 +116,7 @@ const runningState = async (sources: LaneSources, name: string, agent: LaneAgent
|
|
|
118
116
|
gitStatus,
|
|
119
117
|
view: {
|
|
120
118
|
status: { agent: agent?.status ?? null, ...gitStatus },
|
|
119
|
+
...(agent?.title ? { sessionTitle: agent.title } : {}),
|
|
121
120
|
pullRequests: gitStatus.branch ? (pullRequests.byBranch.get(gitStatus.branch) ?? []) : [],
|
|
122
121
|
context: contextOf(agent, source),
|
|
123
122
|
},
|
|
@@ -125,25 +124,31 @@ const runningState = async (sources: LaneSources, name: string, agent: LaneAgent
|
|
|
125
124
|
}
|
|
126
125
|
|
|
127
126
|
/**
|
|
128
|
-
* The progress of a lane with the phase whose boxes moved since the last read as its current one
|
|
129
|
-
*
|
|
127
|
+
* The progress of a lane with the phase whose boxes moved since the last read as its current one, and when each of
|
|
128
|
+
* its phases last moved, a movement bounded by when its plan file was last written (`modified`): the lane's memory in
|
|
129
|
+
* `memories` is updated by this read at `now`. Without `memories` the newest board row decides alone and no movement
|
|
130
|
+
* is known.
|
|
130
131
|
*/
|
|
131
132
|
const progressWithMemory = (
|
|
132
133
|
shown: SprintLane,
|
|
133
134
|
plans: PlansByHref,
|
|
134
135
|
memories: Map<string, LaneMemory> | undefined,
|
|
135
|
-
|
|
136
|
+
{ now, modified }: { now: number; modified: PlanModified },
|
|
137
|
+
): { progress: LaneProgress; activity: ActivityTimes } => {
|
|
136
138
|
const progress = laneProgress(shown, plans)
|
|
137
|
-
if (!memories) return progress
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
if (!memories) return { progress, activity: {} }
|
|
140
|
+
const previous = memories.get(shown.name)
|
|
141
|
+
const memory = nextMemory(previous, plans, newestRowIdentity(progress))
|
|
142
|
+
const activity = nextActivity(previous, memory.ticks, now, modified)
|
|
143
|
+
memories.set(shown.name, { ...memory, activity })
|
|
144
|
+
return { progress: withMovedPhase(progress, memory.moved), activity }
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
const laneView = async (
|
|
144
148
|
sources: LaneSources,
|
|
145
149
|
lane: SprintLane,
|
|
146
150
|
fallbacks: Fallbacks,
|
|
151
|
+
now: number,
|
|
147
152
|
memories?: Map<string, LaneMemory>,
|
|
148
153
|
): Promise<LaneResult> => {
|
|
149
154
|
const { source, agents } = sources
|
|
@@ -151,27 +156,26 @@ const laneView = async (
|
|
|
151
156
|
const worktree = worktreeOf(root, settings, lane.name)
|
|
152
157
|
const agent = agents.find((candidate) => candidate.name === lane.name)
|
|
153
158
|
const { gitStatus, view: running } = await runningState(sources, lane.name, agent)
|
|
154
|
-
const {
|
|
159
|
+
const {
|
|
160
|
+
plans: phasesByHref,
|
|
161
|
+
files,
|
|
162
|
+
texts,
|
|
163
|
+
modified,
|
|
164
|
+
} = await readPlans(
|
|
155
165
|
lane,
|
|
156
166
|
{ root, settings, base: sources.base, sprintDirectory: sources.sprintDirectory },
|
|
157
167
|
laneWorkOf(worktree, gitStatus),
|
|
158
168
|
fallbacks,
|
|
159
169
|
)
|
|
160
|
-
const shown = withWorkedPhases(lane,
|
|
161
|
-
const progress = progressWithMemory(shown,
|
|
170
|
+
const shown = withWorkedPhases(lane, phasesByHref)
|
|
171
|
+
const { progress, activity } = progressWithMemory(shown, phasesByHref, memories, { now, modified })
|
|
162
172
|
const view: LaneView = {
|
|
163
173
|
progress,
|
|
164
174
|
...running,
|
|
165
175
|
chips: Object.fromEntries(
|
|
166
176
|
Object.entries(texts).map(([href, text]) => [href, extractLinkChips(text, settings.linkKinds)]),
|
|
167
177
|
),
|
|
168
|
-
|
|
169
|
-
followingPlans(
|
|
170
|
-
{ lane: shown, agent: agent?.status ?? null },
|
|
171
|
-
{ plans: texts, sprintDirectory: sources.sprintDirectory, language: settings.planLanguage },
|
|
172
|
-
),
|
|
173
|
-
currentPlanPath(progress, sources.sprintDirectory),
|
|
174
|
-
),
|
|
178
|
+
summary: laneSummary({ progress, phases: phasesByHref, texts, activity, language: settings.planLanguage }, now),
|
|
175
179
|
}
|
|
176
180
|
return { view, files, texts, pane: agent?.pane ?? null }
|
|
177
181
|
}
|
|
@@ -318,8 +322,9 @@ export const loadSnapshot = async (
|
|
|
318
322
|
agents,
|
|
319
323
|
pullRequests,
|
|
320
324
|
}
|
|
325
|
+
const now = Date.now()
|
|
321
326
|
const results = [
|
|
322
|
-
...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks, memories)))),
|
|
327
|
+
...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks, now, memories)))),
|
|
323
328
|
...(await Promise.all(unlisted.map((lane) => unlistedView(sources, lane)))),
|
|
324
329
|
]
|
|
325
330
|
forgetLanes(memories, lanes)
|
|
@@ -335,7 +340,7 @@ export const loadSnapshot = async (
|
|
|
335
340
|
})
|
|
336
341
|
const { ticketSources } = settings
|
|
337
342
|
const watched = ticketSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
|
|
338
|
-
const reading = watched ? readWatchReading(root,
|
|
343
|
+
const reading = watched ? readWatchReading(root, now, ticketSources) : null
|
|
339
344
|
const phaseModels = readPhaseModels(root)
|
|
340
345
|
const baseRef = baseRefOf(settings)
|
|
341
346
|
const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
|
|
@@ -361,14 +366,8 @@ export const loadSnapshot = async (
|
|
|
361
366
|
planned,
|
|
362
367
|
files: [sprintFile, ...results.flatMap((result) => result.files)],
|
|
363
368
|
titles: new Map(results.flatMap(({ view, pane }) => (pane ? [[pane, shortTitle(view.progress)]] : []))),
|
|
364
|
-
errors: [
|
|
365
|
-
...errors,
|
|
366
|
-
...pullRequests.errors,
|
|
367
|
-
...(notice ? [notice] : []),
|
|
368
|
-
...(source.boardNotice ? [source.boardNotice] : []),
|
|
369
|
-
],
|
|
369
|
+
errors: [...errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
|
|
370
370
|
watch: reading?.health,
|
|
371
371
|
...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
|
|
372
|
-
language: settings.responseLanguage,
|
|
373
372
|
}
|
|
374
373
|
}
|
package/src/plans/symbols.ts
CHANGED
|
@@ -45,8 +45,8 @@ export const phaseStateOf = ({
|
|
|
45
45
|
/** What a phase shows in place of a symbol: `0/5`, `✓ 15/15`, `–` without checkboxes, `?` when the plan lacks it. */
|
|
46
46
|
export const PHASE_COUNT = { missing: '?', empty: '–', allTicked: '✓' } as const
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
export const MODEL_MARK = {
|
|
48
|
+
/** Between the model a phase's subagent ran on and the plan's recommendation it differs from. */
|
|
49
|
+
export const MODEL_MARK = { differs: '≠' } as const
|
|
50
50
|
|
|
51
51
|
export const phaseCountOf = (phase: { title: string | null; done: number; total: number }): string => {
|
|
52
52
|
const state = phaseStateOf(phase)
|
package/src/plans/texts.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { STALE_ACTIVITY_MINUTES } from './lane-summary'
|
|
2
2
|
import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from './pull-requests'
|
|
3
3
|
import { shownWithMore } from './shown-with-more'
|
|
4
4
|
import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
|
|
5
5
|
|
|
6
|
+
/** The words of an open lane: its section labels, the age of a running phase, the wave, the finished phases not merged yet, and the pointer of a phase with only its review gate open. */
|
|
7
|
+
export type HerdTexts = {
|
|
8
|
+
sections: { session: string; chips: string; now: string; next: string; later: string; done: string }
|
|
9
|
+
minutesAgo: (minutes: number) => string
|
|
10
|
+
wave: (wave: number) => string
|
|
11
|
+
notMerged: string
|
|
12
|
+
reviewGate: string
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
/**
|
|
7
|
-
* Everything the herd says in words but the watcher's badges. The herd is English whatever language the
|
|
8
|
-
*
|
|
16
|
+
* Everything the herd says in words but the watcher's badges. The herd is English whatever language the project
|
|
17
|
+
* answers in; plan and phase titles and tasks come from the plans as written, and the status words are herdr's own terms.
|
|
9
18
|
*/
|
|
10
19
|
export type PlanTexts = {
|
|
11
20
|
noAgent: string
|
|
@@ -15,24 +24,20 @@ export type PlanTexts = {
|
|
|
15
24
|
noWorktree: string
|
|
16
25
|
noRow: string
|
|
17
26
|
notInPlan: string
|
|
18
|
-
noCheckboxes: string
|
|
19
27
|
/** In place of the count of a phase whose only open box is the documentation box of its review gate. */
|
|
20
28
|
allButDocs: string
|
|
21
|
-
tickedUnmerged: string
|
|
22
29
|
tickedSummary: (count: number) => string
|
|
23
30
|
draft: string
|
|
24
31
|
privateLink: string
|
|
25
32
|
planned: (phases: number, plans: number) => string
|
|
26
|
-
|
|
33
|
+
phaseTally: (done: number, total: number) => string
|
|
34
|
+
herd: HerdTexts
|
|
27
35
|
checks: Record<ChecksState, string>
|
|
28
36
|
hint: string
|
|
29
37
|
legend: string[]
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
/**
|
|
33
|
-
* The words of the watcher's badges and of the header notice when they are missing, in the project's response
|
|
34
|
-
* language (D96): they report on the lane's pull request as the watcher's own messages do.
|
|
35
|
-
*/
|
|
40
|
+
/** The words of the watcher's badges and of the header notice when they are missing. */
|
|
36
41
|
export type BadgeTexts = {
|
|
37
42
|
preview: string
|
|
38
43
|
/** The feedback since the last push, without ignored authors. */
|
|
@@ -42,21 +47,12 @@ export type BadgeTexts = {
|
|
|
42
47
|
stale: (minutes: number) => string
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
export const BADGE_TEXTS:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
stale: (minutes) => `Stand ${minutes} min alt`,
|
|
52
|
-
},
|
|
53
|
-
[PLAN_LANGUAGE.en]: {
|
|
54
|
-
preview: '🌐 preview',
|
|
55
|
-
feedback: (count) => `💬 ${count} new`,
|
|
56
|
-
changesRequested: '✋ changes requested',
|
|
57
|
-
watcherOff: 'watcher off',
|
|
58
|
-
stale: (minutes) => `state ${minutes} min old`,
|
|
59
|
-
},
|
|
50
|
+
export const BADGE_TEXTS: BadgeTexts = {
|
|
51
|
+
preview: '🌐 preview',
|
|
52
|
+
feedback: (count) => `💬 ${count} new`,
|
|
53
|
+
changesRequested: '✋ changes requested',
|
|
54
|
+
watcherOff: 'watcher off',
|
|
55
|
+
stale: (minutes) => `state ${minutes} min old`,
|
|
60
56
|
}
|
|
61
57
|
|
|
62
58
|
/** What the herd says about a board, in the word the project calls it (`board.term`). */
|
|
@@ -80,13 +76,18 @@ export const TEXTS: PlanTexts = {
|
|
|
80
76
|
noWorktree: 'no worktree',
|
|
81
77
|
noRow: 'no row in lanes',
|
|
82
78
|
notInPlan: 'not in the plan',
|
|
83
|
-
noCheckboxes: 'no checkboxes',
|
|
84
79
|
allButDocs: ALL_BUT_DOCS,
|
|
85
|
-
tickedUnmerged: 'ticked, not merged',
|
|
86
80
|
tickedSummary: (count) => `${count} ticked · click or a lists them`,
|
|
87
81
|
draft: 'draft · ',
|
|
88
82
|
privateLink: 'private',
|
|
89
|
-
|
|
83
|
+
phaseTally: (done, total) => (total > 0 ? `${done}/${total} ph` : ''),
|
|
84
|
+
herd: {
|
|
85
|
+
sections: { session: 'SESSION', chips: '📎', now: 'NOW', next: 'NEXT', later: 'LATER', done: 'DONE' },
|
|
86
|
+
minutesAgo: (minutes) => (minutes === 0 ? '· just now' : `· ${minutes} min ago`),
|
|
87
|
+
wave: (wave) => `wave ${wave}:`,
|
|
88
|
+
notMerged: 'not merged',
|
|
89
|
+
reviewGate: 'Review gate',
|
|
90
|
+
},
|
|
90
91
|
planned: (phases, plans) =>
|
|
91
92
|
`planned (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
|
|
92
93
|
checks: {
|
|
@@ -99,10 +100,10 @@ export const TEXTS: PlanTexts = {
|
|
|
99
100
|
legend: [
|
|
100
101
|
'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a 📎 chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
|
|
101
102
|
'● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · working and blocked lanes open by themselves, → ← overrule until the status changes',
|
|
102
|
-
`▸ ▾ open and close · ▶
|
|
103
|
-
`◐ lane
|
|
103
|
+
`▸ ▾ open and close · NOW ▶ the phases whose boxes or task lines moved in the last ${STALE_ACTIVITY_MINUTES} min, the one moved last first, · n min ago since then, → the task each is on (Review gate = only its review gate is open) · NEXT the next wave · LATER the other open phases · DONE ✓ the finished ones, not merged = ticked in the worktree, not on the base branch yet · ${ALL_BUT_DOCS} = only the documentation box is open · the lane line names the phase moved last · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes one by one · s opens every lane and plan as one HTML page`,
|
|
104
|
+
`◐ progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
|
|
104
105
|
'↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status under 📎, 📡 <n> in amber on a folded lane when tickets are new',
|
|
105
|
-
'⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main
|
|
106
|
+
"SESSION: the agent's title · ⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main",
|
|
106
107
|
],
|
|
107
108
|
}
|
|
108
109
|
|
package/src/plans/view.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type LaneProgress,
|
|
3
3
|
laneProgress,
|
|
4
|
-
PHASE_ID_PREFIX,
|
|
5
4
|
type PlanStep,
|
|
6
5
|
type PlansByHref,
|
|
6
|
+
phaseLabels,
|
|
7
7
|
type SprintLane,
|
|
8
8
|
} from '../plan-format'
|
|
9
9
|
import { belongsToLane } from './phase-source'
|
|
@@ -24,7 +24,7 @@ const workedStep = (last: PlanStep, ids: string[]): PlanStep => ({
|
|
|
24
24
|
planTitle: last.planTitle,
|
|
25
25
|
planHref: last.planHref,
|
|
26
26
|
phaseIds: ids,
|
|
27
|
-
label: ids
|
|
27
|
+
label: phaseLabels(ids, ' '),
|
|
28
28
|
startsAfter: '',
|
|
29
29
|
model: '',
|
|
30
30
|
withoutRow: true,
|