jackherd 1.15.1 → 1.16.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 +22 -0
- package/README.md +22 -19
- package/dist/{chunk-V4HOLGFP.js → chunk-4LUFR7RC.js} +19 -1
- package/dist/config.d.ts +47 -2
- package/dist/config.js +65 -4
- package/dist/plan-format.js +3 -19
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +49 -4
- package/kit/hooks/hooks/merge-guard/decision.ts +17 -2
- package/kit/workflow/skills/follow/SKILL.md +18 -10
- package/kit/workflow/skills/orchestrate/SKILL.md +9 -6
- package/package.json +2 -1
- package/recipes/README.md +3 -2
- package/recipes/google-calendar.md +30 -0
- package/recipes/sources/google-calendar-today.ts +32 -0
- package/recipes/sources/google-calendar.ts +368 -0
- package/recipes/sources.md +15 -3
- package/schemas/jackherd.schema.json +12 -0
- package/schemas/sources.schema.json +141 -0
- package/src/check/skill-budget-defaults.ts +11 -4
- package/src/cli.ts +1 -0
- package/src/config/json-schema.ts +7 -3
- package/src/config/location.ts +7 -0
- package/src/config/personal-sources.ts +83 -0
- package/src/config/schema.ts +29 -0
- package/src/plans/herd-claim.ts +9 -31
- package/src/plans/layout.ts +135 -18
- package/src/plans/live.ts +54 -7
- package/src/plans/row-actions.ts +17 -10
- package/src/plans/settings.ts +20 -7
- package/src/plans/snapshot.ts +34 -22
- package/src/plans/source-rows.ts +35 -2
- package/src/secrets/command.ts +163 -0
- package/src/secrets/environment.ts +45 -0
- package/src/secrets/hidden-input.ts +51 -0
- package/src/secrets/listing.ts +63 -0
- package/src/secrets/options.ts +45 -0
- package/src/secrets/store.ts +74 -0
- package/src/shared/process-info.ts +89 -0
- package/src/sync/command-context.ts +26 -4
- package/src/sync/render.ts +9 -4
- package/src/watch/command.ts +44 -12
- package/src/watch/lane-report.ts +2 -1
- package/src/watch/plan-badges-read.ts +28 -10
- package/src/watch/source-dry-run-command.ts +23 -12
- package/src/watch/source-dry-run.ts +20 -3
- package/src/watch/source-reload.ts +100 -0
- package/src/watch/source-run.ts +63 -7
- package/src/watch/source-tickets.ts +15 -0
- package/src/watch/sources.ts +21 -0
- package/src/watch/texts.ts +1 -1
- package/src/watch/watch-events.ts +9 -0
- package/src/watch/watched-source.ts +39 -0
- package/src/watch/watcher-lock.ts +8 -1
package/src/plans/layout.ts
CHANGED
|
@@ -98,7 +98,17 @@ import {
|
|
|
98
98
|
import type { SentChip } from './sent-files'
|
|
99
99
|
import { shownWithMore } from './shown-with-more'
|
|
100
100
|
import { askedCheckOf } from './source-asked'
|
|
101
|
-
import {
|
|
101
|
+
import {
|
|
102
|
+
entryBlockItems,
|
|
103
|
+
entryBlockSources,
|
|
104
|
+
type HerdSource,
|
|
105
|
+
type SourceBlock,
|
|
106
|
+
type SourceSummary,
|
|
107
|
+
sourceItems,
|
|
108
|
+
sourceMoreKey,
|
|
109
|
+
sourceSummaries,
|
|
110
|
+
statusOnly,
|
|
111
|
+
} from './source-rows'
|
|
102
112
|
import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord, TURN_MARK } from './symbols'
|
|
103
113
|
import { BADGE_TEXTS, type BadgeTexts, checksText, foldedChecksText, laneMarkText, TEXTS } from './texts'
|
|
104
114
|
import { paintOfRole, paintOfTone, toneOfRole } from './tones'
|
|
@@ -177,14 +187,16 @@ export type OverviewView = {
|
|
|
177
187
|
|
|
178
188
|
/**
|
|
179
189
|
* The drawn screen: a fixed header and footer around a body that scrolls; body lines and targets count from the body's
|
|
180
|
-
* top. `yourTurnBlock` and `sourceBlock` are what a click on these blocks is matched against, null
|
|
181
|
-
* not drawn.
|
|
190
|
+
* top. `yourTurnBlock`, `entryBlocks` and `sourceBlock` are what a click on these blocks is matched against, null (or
|
|
191
|
+
* no entry block) while the block is not drawn.
|
|
182
192
|
*/
|
|
183
193
|
export type Screen = {
|
|
184
194
|
header: string[]
|
|
185
195
|
body: Frame
|
|
186
196
|
footer: string[]
|
|
187
197
|
yourTurnBlock: BlockHit | null
|
|
198
|
+
/** The blocks of the sources that show their entries, in their order under "Your turn". */
|
|
199
|
+
entryBlocks: BlockHit[]
|
|
188
200
|
sourceBlock: BlockHit | null
|
|
189
201
|
/** Every clickable row by the screen line it starts on (header lines first, then the body unscrolled): what the mouse and the keys land on. */
|
|
190
202
|
focusHits: FocusHit[]
|
|
@@ -227,10 +239,12 @@ type TopBlock = {
|
|
|
227
239
|
hidden: number
|
|
228
240
|
expanded: boolean
|
|
229
241
|
rows: BlockRow[]
|
|
242
|
+
/** The mark in front of the name in place of the chevron, for a block that does not fold; absent for the chevron. */
|
|
243
|
+
headMark?: string
|
|
230
244
|
}
|
|
231
245
|
|
|
232
246
|
/** What the head of a block says: its name, its count, its balance and the paint of the count. */
|
|
233
|
-
type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'>
|
|
247
|
+
type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint' | 'headMark'>
|
|
234
248
|
|
|
235
249
|
/** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
|
|
236
250
|
type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
|
|
@@ -903,24 +917,57 @@ const SUBAGENT_ACTIVITY_INDENT = ' '
|
|
|
903
917
|
/** How long a subagent's name may grow before its action, so a narrow pane cuts the name and keeps the action. */
|
|
904
918
|
const SUBAGENT_NAME_CELLS = 28
|
|
905
919
|
|
|
906
|
-
/**
|
|
907
|
-
const
|
|
908
|
-
|
|
920
|
+
/** The key of a lane's activity line, flipped in `expanded`. */
|
|
921
|
+
const laneActivityKey = (lane: string): string => `activity>${lane}`
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* The key of a subagent's activity line, by its label: a subagent that moves up when another ends keeps its own state,
|
|
925
|
+
* and the key is no child of the lane's, so folding one leaves the other.
|
|
926
|
+
*/
|
|
927
|
+
const subagentActivityKey = (lane: string, label: string): string => `subagent-activity>${lane}>${label}`
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* One dim line of activity behind `indent`, cut to the pane on plain text before the paint. A cut line is a row: a
|
|
931
|
+
* click unfolds it over several lines, the text hanging under its first word, and a second click folds it; a line
|
|
932
|
+
* that fits is no click target.
|
|
933
|
+
*/
|
|
934
|
+
const activityFrame = (
|
|
935
|
+
key: string,
|
|
936
|
+
indent: string,
|
|
937
|
+
text: string,
|
|
938
|
+
{ state, width }: { state: Selection; width: number },
|
|
939
|
+
): Frame => {
|
|
940
|
+
const head = `${indent}${ACTIVITY_MARK} `
|
|
941
|
+
const cut = textWidth(`${head}${text}`) > width
|
|
942
|
+
const lines =
|
|
943
|
+
cut && state.expanded.has(key)
|
|
944
|
+
? wrap(text, Math.max(width - textWidth(head), 1)).map((line, index) =>
|
|
945
|
+
index === 0 ? `${head}${line}` : `${' '.repeat(textWidth(head))}${line}`,
|
|
946
|
+
)
|
|
947
|
+
: [fit(`${head}${text}`, width)]
|
|
948
|
+
const dimmed = lines.map((line) => paint.dim(line))
|
|
949
|
+
return cut ? rowFrame(key, dimmed, [], { state, width }) : textFrame(dimmed)
|
|
950
|
+
}
|
|
909
951
|
|
|
910
952
|
/**
|
|
911
953
|
* The last action of a working lane's agent and of each of its running subagents, one dim line each, under the lane
|
|
912
954
|
* line in the column of its children. None while the lane does not work, or without a source for it.
|
|
913
955
|
*/
|
|
914
|
-
const
|
|
956
|
+
const activityFrames = (
|
|
957
|
+
{ activity, status, progress }: LaneView,
|
|
958
|
+
context: { state: Selection; width: number },
|
|
959
|
+
): Frame[] => {
|
|
915
960
|
if (!activity || status.agent !== AGENT_STATUS.working) return []
|
|
961
|
+
const lane = progress.lane.name
|
|
916
962
|
const indent = ' '.repeat(LANE_HEAD_CELLS)
|
|
917
963
|
return [
|
|
918
|
-
...(activity.lane === null ? [] : [
|
|
964
|
+
...(activity.lane === null ? [] : [activityFrame(laneActivityKey(lane), indent, activity.lane, context)]),
|
|
919
965
|
...activity.subagents.map(({ label, description, line }) =>
|
|
920
|
-
|
|
966
|
+
activityFrame(
|
|
967
|
+
subagentActivityKey(lane, label),
|
|
921
968
|
`${indent}${SUBAGENT_ACTIVITY_INDENT}`,
|
|
922
969
|
`${ellipsize(description ?? label, SUBAGENT_NAME_CELLS)}: ${line}`,
|
|
923
|
-
|
|
970
|
+
context,
|
|
924
971
|
),
|
|
925
972
|
),
|
|
926
973
|
]
|
|
@@ -950,8 +997,7 @@ const laneBlock = (
|
|
|
950
997
|
const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
|
|
951
998
|
const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
|
|
952
999
|
const lane = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
|
|
953
|
-
const
|
|
954
|
-
const row = activity.length > 0 ? joinFrames([lane, textFrame(activity)]) : lane
|
|
1000
|
+
const row = joinFrames([lane, ...activityFrames(view, { state, width })])
|
|
955
1001
|
if (!open) return row
|
|
956
1002
|
const inner = narrower(context, LANE_HEAD_CELLS)
|
|
957
1003
|
const head = {
|
|
@@ -1085,9 +1131,13 @@ const blockBar = (): string => ' '
|
|
|
1085
1131
|
* above the block; `▸ <name> · <n> <balance>` while folded. The count (in `countPaint` while it is more than 0) is
|
|
1086
1132
|
* left out for a block without one; the balance comes painted.
|
|
1087
1133
|
*/
|
|
1088
|
-
const blockHead = (
|
|
1134
|
+
const blockHead = (
|
|
1135
|
+
{ name, count, balance, countPaint, headMark }: BlockHeadParts,
|
|
1136
|
+
open: boolean,
|
|
1137
|
+
width: number,
|
|
1138
|
+
): string => {
|
|
1089
1139
|
const counted = count === null ? '' : ` ${ITEM_SEPARATOR} ${count > 0 ? countPaint(String(count)) : count}`
|
|
1090
|
-
const lead = `${paint.dim(chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
|
|
1140
|
+
const lead = `${paint.dim(headMark ?? chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
|
|
1091
1141
|
if (!open) return fit(lead, width)
|
|
1092
1142
|
const cut = fit(lead, Math.max(width - 2, 1))
|
|
1093
1143
|
return `${cut} ${paint.dim(RULE.repeat(Math.max(width - visibleLength(cut) - 1, 0)))}`
|
|
@@ -1226,11 +1276,12 @@ const sourceSummaryText = (summaries: readonly SourceSummary[]): string =>
|
|
|
1226
1276
|
* fails, and a source's entries under it once opened.
|
|
1227
1277
|
*/
|
|
1228
1278
|
const sourceLines = (
|
|
1229
|
-
{ sources, now }: SourceBlock,
|
|
1279
|
+
{ sources: listed, now }: SourceBlock,
|
|
1230
1280
|
awaiting: readonly WaitingItem[],
|
|
1231
1281
|
state: Selection,
|
|
1232
1282
|
width: number,
|
|
1233
1283
|
): BlockLines => {
|
|
1284
|
+
const sources = listed.map(statusOnly)
|
|
1234
1285
|
const shown = state.expanded.has(SOURCE_MORE_KEY) ? sources : sources.slice(0, SHOWN_BLOCK_ROWS)
|
|
1235
1286
|
const asked = askedCheckOf(awaiting)
|
|
1236
1287
|
const newTone = toneOfRole(TONE_ROLE.new)
|
|
@@ -1258,6 +1309,69 @@ const sourceLinesOf = (
|
|
|
1258
1309
|
): BlockLines | null =>
|
|
1259
1310
|
sourceBlock && sourceBlock.sources.length > 0 ? sourceLines(sourceBlock, awaiting, state, width) : null
|
|
1260
1311
|
|
|
1312
|
+
/**
|
|
1313
|
+
* How many entry rows all blocks of entries show together while folded: one calendar keeps the five of the other
|
|
1314
|
+
* blocks, a second one takes from the same five, so the head of the herd, which does not scroll, leaves the lanes room.
|
|
1315
|
+
*/
|
|
1316
|
+
const ENTRY_BLOCK_ROW_BUDGET = SHOWN_BLOCK_ROWS
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* The rows each block of entries shows while folded, in the order of the sources: the shared budget, with one row kept
|
|
1320
|
+
* for each block after it, and at least one row per block, so a block past the budget still shows its first entry; a
|
|
1321
|
+
* block with fewer entries than its share leaves the rest to the blocks after it. More blocks than the budget take one
|
|
1322
|
+
* row each, past the budget.
|
|
1323
|
+
*/
|
|
1324
|
+
const entryRowShares = (counts: readonly number[], budget: number): number[] => {
|
|
1325
|
+
let left = budget
|
|
1326
|
+
return counts.map((count, index) => {
|
|
1327
|
+
const share = Math.min(count, Math.max(left - (counts.length - index - 1), 1))
|
|
1328
|
+
left -= share
|
|
1329
|
+
return share
|
|
1330
|
+
})
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** A source with a block of its own, the clock its times are counted against and the rows it shows while folded. */
|
|
1334
|
+
type EntryBlock = { source: HerdSource; now: number; share: number }
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* The block of a source that shows its entries: always open, headed by its label (else its name) without a chevron,
|
|
1338
|
+
* its `share` of entries and `+<n>` for the rest, which opens all of them and folds them again as in the other blocks.
|
|
1339
|
+
* The head is no click target; an entry with an address opens it.
|
|
1340
|
+
*/
|
|
1341
|
+
const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, width: number): BlockLines => {
|
|
1342
|
+
const moreKey = sourceMoreKey(source.name)
|
|
1343
|
+
const expanded = state.expanded.has(moreKey)
|
|
1344
|
+
const hidden = source.entries.length - share
|
|
1345
|
+
const block: TopBlock = {
|
|
1346
|
+
name: source.label ?? source.name,
|
|
1347
|
+
count: null,
|
|
1348
|
+
balance: '',
|
|
1349
|
+
markPaint: paint.dim,
|
|
1350
|
+
focused: (key) => isFocused(state, key),
|
|
1351
|
+
allBold: (key) => buttonsAllBold(state, key),
|
|
1352
|
+
countPaint: (text) => text,
|
|
1353
|
+
moreKey: hidden > 0 ? moreKey : null,
|
|
1354
|
+
hidden,
|
|
1355
|
+
expanded,
|
|
1356
|
+
rows: allBlockRows(entryBlockItems(source, now, expanded ? source.entries.length : share), new Set(), width),
|
|
1357
|
+
headMark: ' ',
|
|
1358
|
+
}
|
|
1359
|
+
const { lines, hit } = openBlockLines(block, '', width)
|
|
1360
|
+
return { open: true, lines, hit: { rows: hit.rows, moreKey: hit.moreKey } }
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
const entryBlocksOf = (sourceBlock: SourceBlock | undefined, state: Selection, width: number): BlockLines[] => {
|
|
1364
|
+
if (!sourceBlock) return []
|
|
1365
|
+
const sources = entryBlockSources(sourceBlock.sources)
|
|
1366
|
+
const shares = entryRowShares(
|
|
1367
|
+
sources.map(({ entries }) => entries.length),
|
|
1368
|
+
ENTRY_BLOCK_ROW_BUDGET,
|
|
1369
|
+
)
|
|
1370
|
+
return sources.map((source, index) =>
|
|
1371
|
+
entryBlockLines({ source, now: sourceBlock.now, share: shares[index] ?? 0 }, state, width),
|
|
1372
|
+
)
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1261
1375
|
/**
|
|
1262
1376
|
* The blocks one under the other, `offset` lines below the top of the screen, a blank line after each, so one stands
|
|
1263
1377
|
* between neighbours and one between the last and the lanes. An absent block (null) takes no line and has no hit.
|
|
@@ -1429,7 +1543,9 @@ export const renderScreen = (
|
|
|
1429
1543
|
const nowLines = unplaced.map((task) => nowLine(task, width, taskStyle))
|
|
1430
1544
|
const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
|
|
1431
1545
|
const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
|
|
1432
|
-
const
|
|
1546
|
+
const entryBlocks = entryBlocksOf(sourceBlock, state, width)
|
|
1547
|
+
const stack = stackBlocks([turn, ...entryBlocks, listed], above.length)
|
|
1548
|
+
const sourcesSlot = 1 + entryBlocks.length
|
|
1433
1549
|
const sections = [
|
|
1434
1550
|
...(active.length > 0 ? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle })] : []),
|
|
1435
1551
|
...(waiting.length > 0 ? [waitingFrame(waiting, state, width)] : []),
|
|
@@ -1448,7 +1564,8 @@ export const renderScreen = (
|
|
|
1448
1564
|
const body = { ...plainBody, lines: emphasisedAt(plainBody.lines, plainHeader.length, focusHits, state, width) }
|
|
1449
1565
|
return {
|
|
1450
1566
|
yourTurnBlock: stack.hits[0],
|
|
1451
|
-
|
|
1567
|
+
entryBlocks: stack.hits.slice(1, sourcesSlot).filter((hit) => hit !== null),
|
|
1568
|
+
sourceBlock: stack.hits[sourcesSlot],
|
|
1452
1569
|
focusHits: state.showLegend ? focusHits.filter(({ line }) => line < plainHeader.length) : focusHits,
|
|
1453
1570
|
rowKeys: [...blockHits.map(({ key }) => key), ...targetKeys(body.targets)],
|
|
1454
1571
|
header,
|
package/src/plans/live.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { REFRESH_PACE } from './refresh-pace'
|
|
|
35
35
|
import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
|
|
36
36
|
import { closeRow } from './row-actions'
|
|
37
37
|
import { blockRowByKey, type HoverScreen, hoveredSelection, rehovered, withoutHover } from './row-focus'
|
|
38
|
+
import { DONE_KIND, type DoneTarget } from './row-targets'
|
|
38
39
|
import {
|
|
39
40
|
applyClick,
|
|
40
41
|
inputOutcome,
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
startSelection,
|
|
77
78
|
} from './view'
|
|
78
79
|
import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf } from './waiting-click'
|
|
80
|
+
import type { TurnRow, YourTurn } from './your-turn'
|
|
79
81
|
|
|
80
82
|
/**
|
|
81
83
|
* Where the herd's reads come from: its own reader, or the read the watcher publishes for every herd of the board
|
|
@@ -97,6 +99,8 @@ type Live = {
|
|
|
97
99
|
errors: string[]
|
|
98
100
|
/** One footer line about the last action of the person, such as where the sprint page was written; cleared by the next key. */
|
|
99
101
|
notice: string | null
|
|
102
|
+
/** What a `[done]` closed, left out of "Your turn" from the click until a read no longer holds its row. */
|
|
103
|
+
closing: DoneTarget[]
|
|
100
104
|
reported: Map<string, string | null>
|
|
101
105
|
/** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of the page. */
|
|
102
106
|
read: Snapshot | null
|
|
@@ -123,6 +127,39 @@ type Live = {
|
|
|
123
127
|
|
|
124
128
|
const QUIT_KEYS = new Set(['\x03', 'q'])
|
|
125
129
|
|
|
130
|
+
/** Whether `row` of "Your turn" is what `target` closes: a record by its id, a board row or an appointment by its cells. */
|
|
131
|
+
const isClosedBy = (target: DoneTarget, { waiting, appointment }: TurnRow): boolean => {
|
|
132
|
+
switch (target.kind) {
|
|
133
|
+
case DONE_KIND.record:
|
|
134
|
+
return waiting?.id === target.id
|
|
135
|
+
case DONE_KIND.boardRow:
|
|
136
|
+
return waiting?.id === undefined && waiting?.since === target.since && waiting.item === target.item
|
|
137
|
+
case DONE_KIND.appointment:
|
|
138
|
+
return appointment?.when === target.when && appointment.action === target.action
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const isClosing = (closing: readonly DoneTarget[], row: TurnRow): boolean =>
|
|
143
|
+
closing.some((target) => isClosedBy(target, row))
|
|
144
|
+
|
|
145
|
+
/** "Your turn" without the rows `closing` names, so a closed row leaves at the click, not at the next read. */
|
|
146
|
+
export const withoutClosing = (turn: YourTurn, closing: readonly DoneTarget[]): YourTurn => {
|
|
147
|
+
if (closing.length === 0) return turn
|
|
148
|
+
const open = (rows: readonly TurnRow[]) => rows.filter((row) => !isClosing(closing, row))
|
|
149
|
+
return { now: open(turn.now), planned: open(turn.planned) }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The closings a read still holds a row of; one whose row the read no longer holds is closed on the board as well. */
|
|
153
|
+
export const pendingClosings = (turn: YourTurn | undefined, closing: readonly DoneTarget[]): DoneTarget[] => {
|
|
154
|
+
const rows = turn ? [...turn.now, ...turn.planned] : []
|
|
155
|
+
return closing.filter((target) => rows.some((row) => isClosedBy(target, row)))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const withClosingLeftOut = (snapshot: Snapshot, closing: readonly DoneTarget[]): Snapshot =>
|
|
159
|
+
snapshot.yourTurn && closing.length > 0
|
|
160
|
+
? { ...snapshot, yourTurn: { ...snapshot.yourTurn, turn: withoutClosing(snapshot.yourTurn.turn, closing) } }
|
|
161
|
+
: snapshot
|
|
162
|
+
|
|
126
163
|
/** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
|
|
127
164
|
export const changedTitles = (
|
|
128
165
|
titles: ReadonlyMap<string, string | null>,
|
|
@@ -171,7 +208,7 @@ const renderContextOf = (live: Live): RenderContext => ({
|
|
|
171
208
|
|
|
172
209
|
/** The screen for the current state, with the window scrolled to keep the cursor in view. */
|
|
173
210
|
const renderLive = (live: Live, snapshot: Snapshot): { screen: Screen; top: number } => {
|
|
174
|
-
const screen = renderSnapshot(snapshot, renderContextOf(live))
|
|
211
|
+
const screen = renderSnapshot(withClosingLeftOut(snapshot, live.closing), renderContextOf(live))
|
|
175
212
|
return { screen, top: windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen)) }
|
|
176
213
|
}
|
|
177
214
|
|
|
@@ -236,7 +273,7 @@ const followShimmer = (live: Live, prepared: PreparedShimmer) => {
|
|
|
236
273
|
const drawHover = (live: Live) => {
|
|
237
274
|
live.frame = null
|
|
238
275
|
if (!live.snapshot) return
|
|
239
|
-
live.plain ??= renderUnfocused(live.snapshot, renderContextOf(live))
|
|
276
|
+
live.plain ??= renderUnfocused(withClosingLeftOut(live.snapshot, live.closing), renderContextOf(live))
|
|
240
277
|
const laid = focusedScreen(live.plain, live.view.selection, paneWidth(), agentOfSnapshot(live.snapshot))
|
|
241
278
|
if (laid === null) redraw(live)
|
|
242
279
|
else write(live, laid, live.view.top)
|
|
@@ -311,6 +348,7 @@ const takeRead = (live: Live, outcome: ReadOutcome, notes: readonly string[]): b
|
|
|
311
348
|
)
|
|
312
349
|
live.view = { ...live.view, selection: folded }
|
|
313
350
|
live.snapshot = next
|
|
351
|
+
live.closing = pendingClosings(next.yourTurn?.turn, live.closing)
|
|
314
352
|
live.errors = [...next.errors, ...notes, ...reportTitles(live.source, next.titles, live.reported)]
|
|
315
353
|
return redraw(live)
|
|
316
354
|
}
|
|
@@ -474,12 +512,14 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
|
|
|
474
512
|
return
|
|
475
513
|
}
|
|
476
514
|
if ('done' in outcome) {
|
|
477
|
-
|
|
515
|
+
const { closed, notice } = closeRow(outcome.done, {
|
|
478
516
|
sprintFile: live.source.sprintFile,
|
|
479
517
|
language: live.source.settings.planLanguage,
|
|
480
518
|
now: new Date(),
|
|
481
519
|
closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
|
|
482
520
|
})
|
|
521
|
+
if (closed) live.closing = [...live.closing, outcome.done]
|
|
522
|
+
live.notice = notice
|
|
483
523
|
return
|
|
484
524
|
}
|
|
485
525
|
const reason = openInBrowser(outcome.open)
|
|
@@ -487,14 +527,15 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
|
|
|
487
527
|
}
|
|
488
528
|
|
|
489
529
|
/**
|
|
490
|
-
* A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head folds or
|
|
530
|
+
* A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head of `block` folds or
|
|
531
|
+
* opens it (a block of entries, `block` null, has no such head), a
|
|
491
532
|
* cut-off row expands, a file or Artifact opens, a button asks the coordinator or closes the row; the failure or the
|
|
492
533
|
* result shows in the footer.
|
|
493
534
|
*/
|
|
494
|
-
const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input: string): boolean => {
|
|
535
|
+
const clickBlock = (live: Live, block: TopBlockId | null, hit: BlockHit | null, input: string): boolean => {
|
|
495
536
|
const mouse = parseMouse(input)
|
|
496
537
|
if (!mouse || !hit) return false
|
|
497
|
-
if (blockHeadClick(hit.firstLine - 1, mouse)) {
|
|
538
|
+
if (block !== null && blockHeadClick(hit.firstLine - 1, mouse)) {
|
|
498
539
|
live.view = {
|
|
499
540
|
...live.view,
|
|
500
541
|
selection: applyClick(live.view.selection, topBlockHeadKey(block)),
|
|
@@ -533,7 +574,11 @@ const activateBlockRow = (live: Live, screen: Screen, input: string): boolean =>
|
|
|
533
574
|
return true
|
|
534
575
|
}
|
|
535
576
|
|
|
536
|
-
const blocksOf = ({ yourTurnBlock, sourceBlock }: Screen): (BlockHit | null)[] => [
|
|
577
|
+
const blocksOf = ({ yourTurnBlock, entryBlocks, sourceBlock }: Screen): (BlockHit | null)[] => [
|
|
578
|
+
yourTurnBlock,
|
|
579
|
+
...entryBlocks,
|
|
580
|
+
sourceBlock,
|
|
581
|
+
]
|
|
537
582
|
|
|
538
583
|
const handleInput = (live: Live, input: string) => {
|
|
539
584
|
const { snapshot, screen } = live
|
|
@@ -556,6 +601,7 @@ const handleInput = (live: Live, input: string) => {
|
|
|
556
601
|
}
|
|
557
602
|
if (
|
|
558
603
|
clickBlock(live, TOP_BLOCK.yourTurn, screen.yourTurnBlock, input) ||
|
|
604
|
+
screen.entryBlocks.some((hit) => clickBlock(live, null, hit, input)) ||
|
|
559
605
|
clickBlock(live, TOP_BLOCK.sources, screen.sourceBlock, input)
|
|
560
606
|
) {
|
|
561
607
|
redraw(live)
|
|
@@ -594,6 +640,7 @@ export const runLive = (source: Source, key: string | null = null): void => {
|
|
|
594
640
|
view: { selection: overviewSelection(), top: 0, follow: true },
|
|
595
641
|
errors: [],
|
|
596
642
|
notice: null,
|
|
643
|
+
closing: [],
|
|
597
644
|
reported: new Map(),
|
|
598
645
|
read: null,
|
|
599
646
|
whole: null,
|
package/src/plans/row-actions.ts
CHANGED
|
@@ -16,9 +16,16 @@ export type CloseDependencies = {
|
|
|
16
16
|
closeRecords: (ids: readonly string[]) => number
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
/** Whether the row was closed, and the footer line that says so or why not. */
|
|
20
|
+
export type CloseResult = { closed: boolean; notice: string }
|
|
21
|
+
|
|
22
|
+
const closedAs = (subject: string): CloseResult => ({ closed: true, notice: TEXTS.closed(subject) })
|
|
23
|
+
|
|
24
|
+
const notClosed = (reason: string): CloseResult => ({ closed: false, notice: TEXTS.notClosed(reason) })
|
|
25
|
+
|
|
26
|
+
const closeRecord = (id: string, { closeRecords }: CloseDependencies): CloseResult => {
|
|
27
|
+
if (!isQuestionId(id)) return notClosed('not a record id')
|
|
28
|
+
return closeRecords([id]) > 0 ? closedAs(`record ${id}`) : notClosed('record not found or closed already')
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
const closedBoard = (target: DoneTarget, board: string, { language, now }: CloseDependencies): string | null => {
|
|
@@ -31,22 +38,22 @@ const subjectOfRow = (target: DoneTarget): string => {
|
|
|
31
38
|
return target.kind === DONE_KIND.appointment ? `appointment ${boardRowKey(target.when, target.action)}` : ''
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies):
|
|
41
|
+
const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies): CloseResult => {
|
|
35
42
|
try {
|
|
36
43
|
const board = readFileSync(dependencies.sprintFile, 'utf8')
|
|
37
44
|
const closed = closedBoard(target, board, dependencies)
|
|
38
|
-
if (closed === null) return
|
|
45
|
+
if (closed === null) return notClosed('row not found in the board')
|
|
39
46
|
writeFileSync(dependencies.sprintFile, closed)
|
|
40
|
-
return
|
|
47
|
+
return closedAs(subjectOfRow(target))
|
|
41
48
|
} catch (error) {
|
|
42
|
-
return
|
|
49
|
+
return notClosed(errorText(error))
|
|
43
50
|
}
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
/**
|
|
47
54
|
* Closes what a `[done]` button names: a log record by its id, or a row of the board's file by writing the stamp into its
|
|
48
|
-
* status cell. Returns the footer line. The board is read again just before it is written, so a
|
|
49
|
-
* changed since the herd drew it is found by its text or not at all.
|
|
55
|
+
* status cell. Returns whether it closed and the footer line. The board is read again just before it is written, so a
|
|
56
|
+
* row the coordinator changed since the herd drew it is found by its text or not at all.
|
|
50
57
|
*/
|
|
51
|
-
export const closeRow = (target: DoneTarget, dependencies: CloseDependencies):
|
|
58
|
+
export const closeRow = (target: DoneTarget, dependencies: CloseDependencies): CloseResult =>
|
|
52
59
|
target.kind === DONE_KIND.record ? closeRecord(target.id, dependencies) : closeBoardRow(target, dependencies)
|
package/src/plans/settings.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
|
5
5
|
import { readConfig } from '../config/read-config'
|
|
6
6
|
import type { ToneName } from '../config/tones'
|
|
7
7
|
import type { TicketSource } from '../watch/plan-badges'
|
|
8
|
+
import type { SourceShow } from '../watch/sources'
|
|
9
|
+
import { type WatchedSource, watchedSourceOf } from '../watch/watched-source'
|
|
8
10
|
|
|
9
11
|
export const CONFIG_PATH = PROJECT_CONFIG_PATH
|
|
10
12
|
|
|
@@ -20,14 +22,29 @@ export type PlansSettings = Pick<
|
|
|
20
22
|
context: JackherdConfig['plans']['context']
|
|
21
23
|
/** The `watch.sources` marked `ticket`, whose entries the view shows as badges. */
|
|
22
24
|
ticketSources: TicketSource[]
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The other `watch.sources` by name, with the tone each names for a new report and, when set, how it shows its
|
|
27
|
+
* entries; the herd lists them in a block of their own.
|
|
28
|
+
*/
|
|
29
|
+
herdSources: { name: string; tone: ToneName | null; show?: SourceShow }[]
|
|
25
30
|
/** By source name: what the herd puts before a row of "Your turn" that came from the source, its `label` or else its name. */
|
|
26
31
|
sourceLabels: Record<string, string>
|
|
27
32
|
/** The tone of each role in the herd. */
|
|
28
33
|
tones: JackherdConfig['plans']['tones']
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
/** What the herd draws of the watcher's sources: from the watcher's state when it keeps them, else from the config. */
|
|
37
|
+
export type HerdSourceSettings = Pick<PlansSettings, 'ticketSources' | 'herdSources' | 'sourceLabels'>
|
|
38
|
+
|
|
39
|
+
/** The herd's view of `sources`, in their order: the tickets, the others with their tone and `show`, every label. */
|
|
40
|
+
export const herdSourceSettingsOf = (sources: readonly WatchedSource[]): HerdSourceSettings => ({
|
|
41
|
+
ticketSources: sources.flatMap(({ name, lane, ticket }) => (ticket ? [{ name, lane }] : [])),
|
|
42
|
+
herdSources: sources.flatMap(({ name, ticket, tone, show }) =>
|
|
43
|
+
ticket ? [] : [{ name, tone, ...(show === null ? {} : { show }) }],
|
|
44
|
+
),
|
|
45
|
+
sourceLabels: Object.fromEntries(sources.map(({ name, label }) => [name, label ?? name])),
|
|
46
|
+
})
|
|
47
|
+
|
|
31
48
|
/** The locale of the times the herd writes (the refresh error, the last fetch): English, with a 24-hour clock. */
|
|
32
49
|
export const CLOCK_LOCALE = 'en-GB'
|
|
33
50
|
|
|
@@ -45,11 +62,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
45
62
|
shimmer: config.plans.shimmer,
|
|
46
63
|
linkKinds: config.plans.linkKinds,
|
|
47
64
|
context: config.plans.context,
|
|
48
|
-
|
|
49
|
-
ticket ? [{ name, lane: lane ?? null }] : [],
|
|
50
|
-
),
|
|
51
|
-
herdSources: config.watch.sources.flatMap(({ name, ticket, tone }) => (ticket ? [] : [{ name, tone: tone ?? null }])),
|
|
52
|
-
sourceLabels: Object.fromEntries(config.watch.sources.map(({ name, label }) => [name, label ?? name])),
|
|
65
|
+
...herdSourceSettingsOf(config.watch.sources.map(watchedSourceOf)),
|
|
53
66
|
tones: config.plans.tones,
|
|
54
67
|
})
|
|
55
68
|
|
package/src/plans/snapshot.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { isRestingAgent } from '../terminal/types'
|
|
|
23
23
|
import { laneReportPath, readLaneReport } from '../watch/lane-report-file'
|
|
24
24
|
import { restNote } from '../watch/lane-rest'
|
|
25
25
|
import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
|
|
26
|
-
import { readWatchReading } from '../watch/plan-badges-read'
|
|
26
|
+
import { readWatchReading, type SourcedWatchReading } from '../watch/plan-badges-read'
|
|
27
27
|
import type { SourceStatus } from '../watch/source-statuses'
|
|
28
28
|
import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
|
|
29
29
|
import { activityLineOf, type LaneSubagents } from './activity-read'
|
|
@@ -65,7 +65,7 @@ import { markStale, type PlannedPlan, plannedPlans } from './planned'
|
|
|
65
65
|
import type { PullRequest } from './pull-requests'
|
|
66
66
|
import type { ReadCache } from './read-cache'
|
|
67
67
|
import { type SentChip, sentChipsByLane } from './sent-files'
|
|
68
|
-
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
|
|
68
|
+
import { baseRefOf, boardRepoPathOf, type HerdSourceSettings, type PlansSettings, worktreeOf } from './settings'
|
|
69
69
|
import { signalledTitle } from './signalled-phase'
|
|
70
70
|
import { readAllSignalsSliced } from './signals-read'
|
|
71
71
|
import { createSliceGate, type SliceGate } from './slice-gate'
|
|
@@ -194,7 +194,7 @@ type LaneSet = { lanes: SprintLane[]; unlisted: UnlistedLane[]; sources: LaneSou
|
|
|
194
194
|
|
|
195
195
|
/** What a lane's view is decorated with after it was read. */
|
|
196
196
|
type Decorations = {
|
|
197
|
-
reading:
|
|
197
|
+
reading: SourcedWatchReading | null
|
|
198
198
|
phaseModels: readonly PhaseModelRecord[]
|
|
199
199
|
sprintDirectory: string
|
|
200
200
|
sentChips: ReadonlyMap<string | null, SentChip[]>
|
|
@@ -635,17 +635,40 @@ const decoratedViews = (
|
|
|
635
635
|
),
|
|
636
636
|
)
|
|
637
637
|
|
|
638
|
-
/** Each source with the tone and the
|
|
638
|
+
/** Each source with the tone, the label and the `show` its config gives it. */
|
|
639
639
|
const withSourceStyle = (
|
|
640
640
|
statuses: readonly SourceStatus[],
|
|
641
|
-
{ herdSources, sourceLabels }:
|
|
641
|
+
{ herdSources, sourceLabels }: HerdSourceSettings,
|
|
642
642
|
): HerdSource[] =>
|
|
643
643
|
statuses.map((status) => {
|
|
644
644
|
const label = sourceLabels[status.name]
|
|
645
|
-
const
|
|
646
|
-
return {
|
|
645
|
+
const config = herdSources.find(({ name }) => name === status.name)
|
|
646
|
+
return {
|
|
647
|
+
...status,
|
|
648
|
+
tone: config?.tone ?? null,
|
|
649
|
+
...(label === undefined ? {} : { label }),
|
|
650
|
+
...(config?.show === undefined ? {} : { show: config.show }),
|
|
651
|
+
}
|
|
647
652
|
})
|
|
648
653
|
|
|
654
|
+
/**
|
|
655
|
+
* The watcher's reading and the sources the view draws: those the watcher's state names, else the config's. The
|
|
656
|
+
* state is read every time, so a source the watcher took up since the herd started shows; the reading is null while
|
|
657
|
+
* the view follows no source and no pull request.
|
|
658
|
+
*/
|
|
659
|
+
const watchReadingOf = (
|
|
660
|
+
root: string,
|
|
661
|
+
now: number,
|
|
662
|
+
fallback: HerdSourceSettings,
|
|
663
|
+
results: readonly LaneResult[],
|
|
664
|
+
): { reading: SourcedWatchReading | null; sourceSettings: HerdSourceSettings } => {
|
|
665
|
+
const read = readWatchReading(root, now, fallback)
|
|
666
|
+
const { ticketSources, herdSources } = read.sourceSettings
|
|
667
|
+
const watched =
|
|
668
|
+
ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
|
|
669
|
+
return { reading: watched ? read : null, sourceSettings: read.sourceSettings }
|
|
670
|
+
}
|
|
671
|
+
|
|
649
672
|
/**
|
|
650
673
|
* The open phases no lane with an agent leads, a plan marked stale by its last change at `baseCommit`; none while the
|
|
651
674
|
* view shows one lane.
|
|
@@ -712,25 +735,14 @@ export const loadSnapshot = async (
|
|
|
712
735
|
const { parsed } = laneSet.sources
|
|
713
736
|
const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
|
|
714
737
|
const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
|
|
715
|
-
const { ticketSources, herdSources } = settings
|
|
716
|
-
const watched =
|
|
717
|
-
ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
|
|
718
738
|
await gate.pass()
|
|
719
|
-
const reading =
|
|
720
|
-
|
|
721
|
-
root,
|
|
722
|
-
now,
|
|
723
|
-
ticketSources,
|
|
724
|
-
undefined,
|
|
725
|
-
undefined,
|
|
726
|
-
herdSources.map(({ name }) => name),
|
|
727
|
-
)
|
|
728
|
-
: null
|
|
739
|
+
const { reading, sourceSettings } = watchReadingOf(root, now, settings, results)
|
|
740
|
+
const { ticketSources } = sourceSettings
|
|
729
741
|
await gate.pass()
|
|
730
742
|
const waitingRecords = readWaitingRecords(root)
|
|
731
743
|
const waiting = withSourcePrefix(
|
|
732
744
|
waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
|
|
733
|
-
|
|
745
|
+
sourceSettings.sourceLabels,
|
|
734
746
|
)
|
|
735
747
|
const sentChips = sentChipsByLane(waitingRecords.sent, worktrees, now)
|
|
736
748
|
const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
|
|
@@ -782,7 +794,7 @@ export const loadSnapshot = async (
|
|
|
782
794
|
waiting,
|
|
783
795
|
groomedDays: groomedDaysAgo(liveBoard, settings.planLanguage, new Date(now)),
|
|
784
796
|
yourTurn: yourTurnOfBoard(liveBoard, settings.planLanguage, waiting, now),
|
|
785
|
-
...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources,
|
|
797
|
+
...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources, sourceSettings), now } } : {}),
|
|
786
798
|
tones: settings.tones,
|
|
787
799
|
...(coordinator ? { coordinator: coordinator.view } : {}),
|
|
788
800
|
unplaced: unplacedOf(unplacedSources, laneSet.lanes, Boolean(options.lane)),
|