jackherd 1.6.1 → 1.7.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 +26 -0
- package/INSTALL.md +176 -0
- package/README.md +226 -310
- package/assets/branding/README.md +7 -3
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -1
- package/assets/icons/bark-dark.svg +1 -0
- package/assets/icons/bark.svg +1 -0
- package/assets/icons/bowl-dark.svg +1 -0
- package/assets/icons/bowl.svg +1 -0
- package/assets/icons/decide-dark.svg +1 -0
- package/assets/icons/decide.svg +1 -0
- package/assets/icons/ear-dark.svg +1 -0
- package/assets/icons/ear.svg +1 -0
- package/assets/icons/lanes-dark.svg +1 -0
- package/assets/icons/lanes.svg +1 -0
- package/assets/icons/plan-dark.svg +1 -0
- package/assets/icons/plan.svg +1 -0
- package/assets/icons/space.svg +1 -0
- package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
- package/dist/config.d.ts +14 -3
- package/dist/config.js +111 -10
- package/dist/plan-format.d.ts +180 -77
- package/dist/plan-format.js +292 -93
- package/docs/lanes-dark.svg +50 -0
- package/docs/lanes.svg +50 -1
- package/docs/skills-dark.svg +65 -0
- package/docs/skills.svg +65 -1
- package/docs/workflow-dark.svg +138 -0
- package/docs/workflow.svg +135 -169
- package/herdr-plugin/herdr-plugin.toml +6 -6
- package/herdr-plugin/open-plans.ts +1 -1
- package/herdr-plugin/restore-plans.ts +7 -7
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
- package/kit/hooks/hooks/approval-gate/index.ts +69 -0
- package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
- package/kit/hooks/hooks/approval-gate/request.ts +111 -0
- package/kit/hooks/hooks/approval-gate/run.ts +94 -0
- package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
- package/kit/hooks/hooks/approval-gate/store.ts +70 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
- package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
- package/kit/hooks/hooks/merge-guard/run.ts +2 -1
- package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
- package/kit/hooks/hooks/model-gate/index.ts +58 -0
- package/kit/hooks/hooks/model-gate/run.ts +63 -0
- package/kit/hooks/hooks/model-log/index.ts +60 -0
- package/kit/hooks/hooks/model-log/run.ts +57 -0
- package/kit/hooks/hooks/model-log/store.ts +40 -0
- package/kit/hooks/hooks/model-log/transcript.ts +37 -0
- package/kit/hooks/hooks/shared/checkout.ts +22 -0
- package/kit/hooks/hooks/shared/command.ts +6 -0
- package/kit/hooks/hooks/shared/config.ts +21 -0
- package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
- package/kit/hooks/hooks/shared/files.ts +19 -0
- package/kit/hooks/hooks/shared/fresh.ts +4 -0
- package/kit/hooks/hooks/shared/json.ts +4 -0
- package/kit/hooks/hooks/shared/model-family.ts +48 -0
- package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
- package/kit/hooks/hooks/shared/private-directory.ts +29 -0
- package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
- package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
- package/kit/hooks/hooks.json +25 -0
- package/kit/workflow/skills/follow/SKILL.md +4 -3
- package/kit/workflow/skills/implement/SKILL.md +6 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +109 -27
- package/kit/workflow/skills/plan/SKILL.md +5 -5
- package/kit/workflow/skills/start/SKILL.md +2 -2
- package/package.json +4 -1
- package/schemas/jackherd.schema.json +43 -5
- package/src/check/command.ts +31 -4
- package/src/check/deferral-arrows.ts +1 -1
- package/src/check/parallel-report.ts +23 -0
- package/src/check/plan-structure-check.ts +120 -53
- package/src/cli.ts +8 -2
- package/src/config/nested-quantifier.ts +73 -0
- package/src/config/repository-directory.ts +11 -0
- package/src/config/schema.ts +63 -8
- package/src/plan-format/board-prose.ts +36 -0
- package/src/plan-format/dependencies.ts +227 -0
- package/src/plan-format/documentation-gate.ts +16 -0
- package/src/plan-format/index.ts +23 -1
- package/src/plan-format/lanes.ts +35 -1
- package/src/plan-format/model-column.ts +52 -0
- package/src/plan-format/phases.ts +16 -1
- package/src/plan-format/plan-structure.ts +10 -28
- package/src/plan-format/progress.ts +7 -29
- package/src/plans/command.ts +3 -3
- package/src/plans/current-phase.ts +71 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/herd-command.ts +4 -0
- package/src/plans/lane-discovery.ts +14 -2
- package/src/plans/lane-tickets.ts +69 -0
- package/src/plans/layout.ts +182 -88
- package/src/plans/live.ts +5 -1
- package/src/plans/phase-models-read.ts +37 -0
- package/src/plans/phase-models.ts +70 -0
- package/src/plans/plan-panes.ts +8 -7
- package/src/plans/pull-requests.ts +78 -4
- package/src/plans/selection.ts +1 -40
- package/src/plans/settings.ts +9 -3
- package/src/plans/shown-with-more.ts +5 -0
- package/src/plans/snapshot.ts +82 -8
- package/src/plans/sprint-html.ts +2 -2
- package/src/plans/supervisor.ts +5 -4
- package/src/plans/symbols.ts +9 -6
- package/src/plans/texts.ts +30 -13
- package/src/plans/view.ts +1 -0
- package/src/sync/herdr-plugin.ts +1 -1
- package/src/sync/next-steps.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/setup-questions.ts +2 -2
- package/src/terminal/text.ts +4 -0
- package/src/terminal/types.ts +1 -1
- package/src/vocabulary/index.ts +39 -0
- package/src/watch/approvals.ts +96 -0
- package/src/watch/command.ts +154 -50
- package/src/watch/failed-tests.ts +52 -0
- package/src/watch/failure-read.ts +56 -0
- package/src/watch/failures.ts +107 -0
- package/src/watch/feedback-item.ts +21 -0
- package/src/watch/feedback-read.ts +2 -1
- package/src/watch/feedback.ts +37 -28
- package/src/watch/gh-process.ts +53 -0
- package/src/watch/gh.ts +10 -20
- package/src/watch/heartbeat-progress.ts +1 -1
- package/src/watch/lane-model.ts +54 -0
- package/src/watch/lane-parallel.ts +46 -0
- package/src/watch/lane-progress.ts +28 -4
- package/src/watch/lane-report.ts +62 -9
- package/src/watch/lane-sources.ts +1 -1
- package/src/watch/no-finding.ts +47 -0
- package/src/watch/plan-badges-read.ts +19 -7
- package/src/watch/plan-badges.ts +59 -7
- package/src/watch/plan-views.ts +5 -5
- package/src/watch/previews.ts +3 -9
- package/src/watch/pull-requests.ts +12 -2
- package/src/watch/recorded-reads.ts +25 -0
- package/src/watch/source-fields.ts +4 -0
- package/src/watch/source-tickets.ts +99 -0
- package/src/watch/sources.ts +62 -15
- package/src/watch/texts.ts +21 -1
- package/src/watch/watch-defaults.ts +8 -0
- package/src/watch/watch-events.ts +64 -4
- package/src/watch/watcher-lock.ts +1 -1
- package/src/watch/workflow-runs.ts +5 -7
- package/docs/lanes.mmd +0 -9
- package/docs/skills.mmd +0 -10
package/src/plans/layout.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
|
|
2
|
-
import type { ChecklistItem, DeferredItem, LaneProgress, PhaseProgress,
|
|
3
|
-
import { activeItem, currentPhase, isTickedUnmerged, PHASE_ID_PREFIX } from '../plan-format'
|
|
2
|
+
import type { ChecklistItem, DeferredItem, LaneProgress, PhaseProgress, StepProgress } from '../plan-format'
|
|
3
|
+
import { activeItem, currentPhase, isTickedUnmerged, onlyDocumentationOpen, PHASE_ID_PREFIX } from '../plan-format'
|
|
4
4
|
import {
|
|
5
5
|
BADGE_TONE,
|
|
6
6
|
BAR_SEGMENT,
|
|
7
7
|
type BadgeTone,
|
|
8
8
|
badge,
|
|
9
9
|
bar,
|
|
10
|
+
box,
|
|
10
11
|
fit,
|
|
11
12
|
INVERSE_ON,
|
|
12
13
|
packGroups,
|
|
@@ -24,12 +25,15 @@ import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
|
24
25
|
import {
|
|
25
26
|
NO_BADGES,
|
|
26
27
|
type PullRequestBadges,
|
|
28
|
+
type TicketBadge,
|
|
27
29
|
WATCH_HEALTH,
|
|
28
30
|
type WatchBadges,
|
|
29
31
|
type WatchHealth,
|
|
30
32
|
} from '../watch/plan-badges'
|
|
31
33
|
import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
|
|
32
|
-
import {
|
|
34
|
+
import type { LaneTickets } from './lane-tickets'
|
|
35
|
+
import { chipKey, type LinkChip, planUrlOf } from './link-chips'
|
|
36
|
+
import { type PhaseModel, phaseModelKey } from './phase-models'
|
|
33
37
|
import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
|
|
34
38
|
import { CHECKS, type ChecksState, type PullRequest, pullRequestKey } from './pull-requests'
|
|
35
39
|
import {
|
|
@@ -38,7 +42,6 @@ import {
|
|
|
38
42
|
isLaneOpen,
|
|
39
43
|
itemKey,
|
|
40
44
|
laneKey,
|
|
41
|
-
notesKey,
|
|
42
45
|
PLANNED_KEY,
|
|
43
46
|
phaseKey,
|
|
44
47
|
type Selection,
|
|
@@ -46,8 +49,10 @@ import {
|
|
|
46
49
|
targetKeys,
|
|
47
50
|
tickedKey,
|
|
48
51
|
} from './selection'
|
|
52
|
+
import { shownWithMore } from './shown-with-more'
|
|
49
53
|
import {
|
|
50
54
|
chevronOf,
|
|
55
|
+
MODEL_MARK,
|
|
51
56
|
NO_AGENT_SYMBOL,
|
|
52
57
|
NO_AGENT_WORD,
|
|
53
58
|
PHASE_COUNT,
|
|
@@ -57,7 +62,7 @@ import {
|
|
|
57
62
|
statusSymbol,
|
|
58
63
|
statusWord,
|
|
59
64
|
} from './symbols'
|
|
60
|
-
import { BADGE_TEXTS, type BadgeTexts, laneMarkText, TEXTS } from './texts'
|
|
65
|
+
import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
|
|
61
66
|
|
|
62
67
|
/**
|
|
63
68
|
* Where a lane stands outside its plan. `agent` is the terminal adapter's status, null when no agent is listed;
|
|
@@ -82,6 +87,10 @@ export type LaneView = {
|
|
|
82
87
|
unlisted?: boolean
|
|
83
88
|
/** Preview and feedback of the lane's pull requests from the running watcher's state; none without a watcher. */
|
|
84
89
|
badges?: WatchBadges
|
|
90
|
+
/** The tickets of the lane from the running watcher's state; absent when it has none or no watcher runs. */
|
|
91
|
+
tickets?: LaneTickets
|
|
92
|
+
/** The model each phase's implementing subagent last ran on, by `phaseModelKey`; absent without a record. */
|
|
93
|
+
models?: Record<string, PhaseModel>
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
/**
|
|
@@ -94,6 +103,8 @@ export type OverviewView = {
|
|
|
94
103
|
lanes: LaneView[]
|
|
95
104
|
planned: PlannedPlan[]
|
|
96
105
|
watch?: WatchHealth
|
|
106
|
+
/** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
|
|
107
|
+
watchesTickets?: boolean
|
|
97
108
|
language?: PlanLanguage
|
|
98
109
|
}
|
|
99
110
|
|
|
@@ -114,6 +125,10 @@ type LaneContext = {
|
|
|
114
125
|
chips: Record<string, LinkChip[]>
|
|
115
126
|
badges: WatchBadges
|
|
116
127
|
badgeTexts: BadgeTexts
|
|
128
|
+
/** The lane's tickets for the plan block that shows them, the first; null in every other block. */
|
|
129
|
+
tickets: LaneTickets | null
|
|
130
|
+
/** The model each phase ran on, by `phaseModelKey`. */
|
|
131
|
+
models: Record<string, PhaseModel>
|
|
117
132
|
}
|
|
118
133
|
|
|
119
134
|
/**
|
|
@@ -143,20 +158,24 @@ const TREE_GUTTER = TREE.branch.length
|
|
|
143
158
|
|
|
144
159
|
/** Places on each side of the slash of a count: ` 9/9 `, ` 15/15 `, `123/456`. */
|
|
145
160
|
const COUNT_PLACES = 3
|
|
146
|
-
/** The cells before a count:
|
|
161
|
+
/** The cells before a count: `◐`, `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
|
|
147
162
|
const COUNT_MARK_WIDTH = 2
|
|
148
163
|
const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
|
|
149
164
|
const MIN_TITLE_WIDTH = 8
|
|
150
165
|
const MIN_BESIDE_WIDTH = 24
|
|
151
166
|
const CONTEXT_WIDTH = 5
|
|
152
|
-
const CONTEXT_SYMBOL = '
|
|
153
|
-
const PROGRESS_SYMBOL = '
|
|
167
|
+
const CONTEXT_SYMBOL = '🥣'
|
|
168
|
+
const PROGRESS_SYMBOL = '◐'
|
|
169
|
+
const HEADER_MARK = '🐾'
|
|
154
170
|
const COLUMN_RULE = '│'
|
|
155
171
|
/** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
|
|
156
172
|
const PULL_REQUEST_BADGE_WIDTH = 8
|
|
157
173
|
/** Under the lane line's status symbol, where the pull request row and its badge line start (D96). */
|
|
158
174
|
const PULL_REQUEST_INDENT = ' '
|
|
159
175
|
const CHECKS_WIDTH = 10
|
|
176
|
+
/** The most failed tests listed under a pull request; the rest is counted. */
|
|
177
|
+
const SHOWN_FAILED_TESTS = 3
|
|
178
|
+
const FAILED_TEST_INDENT = ' '
|
|
160
179
|
const MARKDOWN_MARKS = /\*\*|`/g
|
|
161
180
|
|
|
162
181
|
/** Lower ranks need the coordinator sooner; a status an adapter adds later ranks after `working`. */
|
|
@@ -169,10 +188,11 @@ const URGENCY: Record<AgentStatus, number> = {
|
|
|
169
188
|
const UNKNOWN_URGENCY = 3
|
|
170
189
|
const NO_AGENT_URGENCY = 4
|
|
171
190
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
[AGENT_STATUS.
|
|
175
|
-
[AGENT_STATUS.
|
|
191
|
+
/** The colour of a lane state, word and symbol alike: teal while working or done, bold amber when it waits for you, grey when idle. */
|
|
192
|
+
const STATUS_PAINT: Record<AgentStatus, (text: string) => string> = {
|
|
193
|
+
[AGENT_STATUS.working]: paint.teal,
|
|
194
|
+
[AGENT_STATUS.blocked]: (text) => paint.bold(paint.yellow(text)),
|
|
195
|
+
[AGENT_STATUS.done]: paint.teal,
|
|
176
196
|
[AGENT_STATUS.idle]: paint.dim,
|
|
177
197
|
}
|
|
178
198
|
|
|
@@ -207,19 +227,28 @@ const PHASE_PAINT: Record<PhaseState, (count: string) => string> = {
|
|
|
207
227
|
const countText = (done: number, total: number): string =>
|
|
208
228
|
`${String(done).padStart(COUNT_PLACES)}/${String(total).padEnd(COUNT_PLACES)}`
|
|
209
229
|
|
|
230
|
+
const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
|
|
231
|
+
|
|
210
232
|
/** Mark and count in one fixed-width cell at the right edge, the same for the lane, phase and planned rows. */
|
|
211
233
|
const countCell = (mark: string, count: string): string => `${padVisible(mark, COUNT_MARK_WIDTH)} ${count}`
|
|
212
234
|
|
|
235
|
+
/** A word in place of a count, right-aligned in the width of the count cell like `n/m`. */
|
|
236
|
+
const wordCell = (word: string): string => `${' '.repeat(Math.max(COUNT_CELL_WIDTH - textWidth(word), 0))}${word}`
|
|
237
|
+
|
|
213
238
|
const PHASE_MARK: Record<PhaseState, string> = {
|
|
214
239
|
[PHASE_STATE.missing]: PHASE_COUNT.missing,
|
|
215
240
|
[PHASE_STATE.empty]: PHASE_COUNT.empty,
|
|
216
|
-
[PHASE_STATE.all]:
|
|
241
|
+
[PHASE_STATE.all]: '',
|
|
217
242
|
[PHASE_STATE.open]: '',
|
|
218
243
|
}
|
|
219
244
|
|
|
220
|
-
/** The count cell of a phase row at the right edge; the current phase is bold yellow. */
|
|
245
|
+
/** 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. */
|
|
221
246
|
const phaseCountCell = (phase: PhaseProgress, current: boolean): string => {
|
|
222
247
|
const state = phaseStateOf(phase)
|
|
248
|
+
if (state === PHASE_STATE.open && onlyDocumentationOpen(phase)) {
|
|
249
|
+
const cell = wordCell(TEXTS.allButDocs)
|
|
250
|
+
return current ? paint.bold(paint.yellow(cell)) : paint.green(cell)
|
|
251
|
+
}
|
|
223
252
|
const hasCount = state === PHASE_STATE.open || state === PHASE_STATE.all
|
|
224
253
|
const cell = countCell(PHASE_MARK[state], hasCount ? countText(phase.done, phase.total) : BLANK_COUNT)
|
|
225
254
|
return current ? paint.bold(paint.yellow(cell)) : PHASE_PAINT[state](cell)
|
|
@@ -227,13 +256,16 @@ const phaseCountCell = (phase: PhaseProgress, current: boolean): string => {
|
|
|
227
256
|
|
|
228
257
|
const agentLabel = (agent: string | null): string => {
|
|
229
258
|
if (agent === null) return paint.dim(statusWord(agent))
|
|
230
|
-
return isAgentStatus(agent) ?
|
|
259
|
+
return isAgentStatus(agent) ? STATUS_PAINT[agent](statusWord(agent)) : statusWord(agent)
|
|
231
260
|
}
|
|
232
261
|
|
|
233
262
|
/** The symbol of a status padded to `SYMBOL_WIDTH` cells, so the names after it start in one column. */
|
|
234
263
|
const symbolCell = (agent: string | null): string => {
|
|
235
264
|
const symbol = statusSymbol(agent)
|
|
236
|
-
return padVisible(
|
|
265
|
+
return padVisible(
|
|
266
|
+
agent !== null && isAgentStatus(agent) ? STATUS_PAINT[agent](symbol) : paint.dim(symbol),
|
|
267
|
+
SYMBOL_WIDTH,
|
|
268
|
+
)
|
|
237
269
|
}
|
|
238
270
|
|
|
239
271
|
const aheadLabel = ({ branch, ahead }: LaneStatus): string => {
|
|
@@ -431,11 +463,39 @@ export const rowWithCount = (
|
|
|
431
463
|
const unmergedMark = (phase: PhaseProgress): string =>
|
|
432
464
|
isTickedUnmerged(phase) ? ` ${paint.yellow(TEXTS.tickedUnmerged)}` : ''
|
|
433
465
|
|
|
434
|
-
/** The
|
|
435
|
-
const
|
|
466
|
+
/** The chevron of a phase row; a finished phase that is closed shows a green ✓ in its place. */
|
|
467
|
+
const phaseMark = (phase: PhaseProgress, open: boolean): string =>
|
|
468
|
+
!open && phaseStateOf(phase) === PHASE_STATE.all ? paint.green(PHASE_COUNT.allTicked) : paint.dim(chevronOf(open))
|
|
469
|
+
|
|
470
|
+
const familyLabel = (family: string): string => `${family.charAt(0).toUpperCase()}${family.slice(1)}`
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The model the phase's subagent last ran on: `Opus ✓` when it fits the plan's recommendation, `Sonnet ≠ Opus` in
|
|
474
|
+
* amber when it does not, the family alone, dim, when the plan recommends none; empty without a record.
|
|
475
|
+
*/
|
|
476
|
+
const modelMark = (model: PhaseModel | undefined): string => {
|
|
477
|
+
if (!model) return ''
|
|
478
|
+
const used = familyLabel(model.used)
|
|
479
|
+
if (model.recommended === null) return paint.dim(used)
|
|
480
|
+
return model.fits
|
|
481
|
+
? `${paint.dim(used)} ${paint.green(MODEL_MARK.fits)}`
|
|
482
|
+
: paint.yellow(`${used} ${MODEL_MARK.differs} ${familyLabel(model.recommended)}`)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The phase title cut before the model and the count, which stay at the right edge, or the note that the plan lacks
|
|
487
|
+
* the phase. The model gives way in a pane too narrow to leave the title `MIN_TITLE_WIDTH` cells.
|
|
488
|
+
*/
|
|
489
|
+
const phaseRow = (phase: PhaseProgress, open: boolean, current: boolean, width: number, model?: PhaseModel): string => {
|
|
436
490
|
const title = phase.title === null ? paint.dim(TEXTS.notInPlan) : plain(phase.title)
|
|
437
|
-
const
|
|
438
|
-
|
|
491
|
+
const label = `${phaseMark(phase, open)} P${phase.id} `
|
|
492
|
+
const head = `${label}${title}${current ? unmergedMark(phase) : ''}`
|
|
493
|
+
const count = phaseCountCell(phase, current)
|
|
494
|
+
const withModel = `${modelMark(model)}${COLUMN_SEPARATOR}${count}`
|
|
495
|
+
const titleRoom = width - visibleLength(label) - visibleLength(withModel) - 1
|
|
496
|
+
const fitsModel =
|
|
497
|
+
model !== undefined && (titleRoom >= MIN_TITLE_WIDTH || titleRoom >= visibleLength(head) - visibleLength(label))
|
|
498
|
+
return spread(head, fitsModel ? withModel : count, width)
|
|
439
499
|
}
|
|
440
500
|
|
|
441
501
|
/** A closed phase shows only `active` under it, when given, closed whatever the task's fold. */
|
|
@@ -459,7 +519,8 @@ const phaseFrame = (context: StepContext, phase: PhaseProgress): Frame => {
|
|
|
459
519
|
const inner = deeper(context)
|
|
460
520
|
const active = current ? activeItem(phase) : null
|
|
461
521
|
const children = open ? phaseChildren(inner, key, phase, active) : [activeTaskFrame(inner, key, phase, active)]
|
|
462
|
-
|
|
522
|
+
const model = context.models[phaseModelKey(stepIndex, phase.id)]
|
|
523
|
+
return joinFrames([rowFrame(key, [phaseRow(phase, open, current, width, model)], [], context), nest(children)])
|
|
463
524
|
}
|
|
464
525
|
|
|
465
526
|
/** True when number, checks and the badges leave room for a title beside them on one line. */
|
|
@@ -471,9 +532,12 @@ const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
|
|
|
471
532
|
* columns, the watcher's badges after them and the title last, cut with `…` on its one line. When the badges leave no
|
|
472
533
|
* room for a title, they go on lines of their own below, starting in the column of the lane's status symbol (D96).
|
|
473
534
|
*/
|
|
474
|
-
const
|
|
535
|
+
const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame => {
|
|
475
536
|
const number = padVisible(badge(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
|
|
476
|
-
const
|
|
537
|
+
const checksWords = checksText(pullRequest)
|
|
538
|
+
const checksColumn =
|
|
539
|
+
textWidth(checksWords) < CHECKS_WIDTH ? CHECKS_WIDTH : textWidth(checksWords) + COLUMN_SEPARATOR.length
|
|
540
|
+
const checks = padVisible(CHECKS_PAINT[pullRequest.checks](checksWords), checksColumn)
|
|
477
541
|
const head = `${PULL_REQUEST_INDENT}${number}${COLUMN_SEPARATOR}${checks}`
|
|
478
542
|
const prefix = pullRequest.draft ? TEXTS.draft : ''
|
|
479
543
|
const dimPrefix = (line: string, index: number) =>
|
|
@@ -495,6 +559,23 @@ const pullRequestFrame = (pullRequest: PullRequest, context: LaneContext): Frame
|
|
|
495
559
|
return { ...row, targets: [...cellTargets(cells, 0, visibleLength(head), context.width), ...row.targets] }
|
|
496
560
|
}
|
|
497
561
|
|
|
562
|
+
/** The failed tests of a red pull request, at most `SHOWN_FAILED_TESTS` of them one per line, then `+<n>`; none otherwise. */
|
|
563
|
+
const failedTestLines = (pullRequest: PullRequest, badges: PullRequestBadges | undefined, width: number): string[] => {
|
|
564
|
+
const tests = pullRequest.checks === CHECKS.failed ? (badges?.failedTests ?? []) : []
|
|
565
|
+
const { shown, more } = shownWithMore(tests, SHOWN_FAILED_TESTS)
|
|
566
|
+
const lines = shown.map((name) => `${FAILED_TEST_INDENT}${paint.red(`✗ ${plain(name)}`)}`)
|
|
567
|
+
return [...lines, ...(more > 0 ? [`${FAILED_TEST_INDENT}${paint.dim(`+${more}`)}`] : [])].map((line) =>
|
|
568
|
+
fit(line, width),
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** The pull request row, then the tests its red checks failed, which only the watcher knows. */
|
|
573
|
+
const pullRequestFrame = (pullRequest: PullRequest, context: LaneContext): Frame =>
|
|
574
|
+
joinFrames([
|
|
575
|
+
pullRequestRow(pullRequest, context),
|
|
576
|
+
textFrame(failedTestLines(pullRequest, context.badges[String(pullRequest.number)], context.width)),
|
|
577
|
+
])
|
|
578
|
+
|
|
498
579
|
const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
|
|
499
580
|
[CONTEXT_LEVEL.ok]: paint.dim,
|
|
500
581
|
[CONTEXT_LEVEL.warn]: paint.yellow,
|
|
@@ -521,26 +602,17 @@ const groupSteps = (steps: readonly StepProgress[]): number[][] =>
|
|
|
521
602
|
return groups
|
|
522
603
|
}, [])
|
|
523
604
|
|
|
524
|
-
const isTicked = ({ phase }: PhaseEntry): boolean => phase.total > 0 && phase.done === phase.total
|
|
525
|
-
|
|
526
|
-
/** A ticked phase folds into the ticked line, unless it is the current one: that one stays a row of its own. */
|
|
527
|
-
const foldsAway = (progress: LaneProgress, entry: PhaseEntry): boolean =>
|
|
528
|
-
isTicked(entry) && !isCurrent(progress, entry.stepIndex, entry.phase)
|
|
529
|
-
|
|
530
|
-
const tickedLine = (entries: PhaseEntry[]): string[] =>
|
|
531
|
-
entries.length === 0
|
|
532
|
-
? []
|
|
533
|
-
: [`${paint.green('✓')} ${paint.dim(`${entries.map(({ phase }) => `P${phase.id}`).join(' ')} ${TEXTS.ticked}`)}`]
|
|
534
|
-
|
|
535
605
|
const CHIP_GAP = ' '
|
|
536
606
|
const CHIP_MARK = '📎 '
|
|
607
|
+
const TICKET_MARK = '📡'
|
|
608
|
+
const STATUS_SEPARATOR = ' · '
|
|
537
609
|
|
|
538
610
|
/** `✦ label` as a badge linked to the chip's address, `private` faint after it for a link its plan says is private. */
|
|
539
611
|
const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
|
|
540
612
|
badge(url, `${icon} ${plain(label)}`, hidden ? TEXTS.privateLink : '')
|
|
541
613
|
|
|
542
|
-
/** A badge and the key a click on it opens. */
|
|
543
|
-
type BadgeCell = { cell: string; key: string }
|
|
614
|
+
/** A badge and the key a click on it opens; null for a cell no click opens. */
|
|
615
|
+
type BadgeCell = { cell: string; key: string | null }
|
|
544
616
|
|
|
545
617
|
const packedWidth = (cells: BadgeCell[]): number =>
|
|
546
618
|
cells.reduce((sum, { cell }, index) => sum + visibleLength(cell) + (index > 0 ? CHIP_GAP.length : 0), 0)
|
|
@@ -548,8 +620,9 @@ const packedWidth = (cells: BadgeCell[]): number =>
|
|
|
548
620
|
/** One click target per badge of a line, from `column` on, each over exactly the columns it covers up to `edge`. */
|
|
549
621
|
const cellTargets = (cells: BadgeCell[], line: number, column: number, edge: number): ClickTarget[] => {
|
|
550
622
|
let from = column
|
|
551
|
-
return cells.
|
|
552
|
-
const
|
|
623
|
+
return cells.flatMap(({ cell, key }) => {
|
|
624
|
+
const to = Math.min(from + visibleLength(cell), edge)
|
|
625
|
+
const target = key === null ? [] : [{ line, from, to, key }]
|
|
553
626
|
from += visibleLength(cell) + CHIP_GAP.length
|
|
554
627
|
return target
|
|
555
628
|
})
|
|
@@ -578,13 +651,36 @@ const packedFrame = (cells: BadgeCell[], lead: string, width: number): Frame =>
|
|
|
578
651
|
return { lines, cursorLine: -1, targets }
|
|
579
652
|
}
|
|
580
653
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
654
|
+
const ticketTone = (isNew: boolean): BadgeTone => (isNew ? BADGE_TONE.amber : BADGE_TONE.grey)
|
|
655
|
+
|
|
656
|
+
/** A badge linked to `url`, or a box no click opens when there is none. */
|
|
657
|
+
const linkedBox = (url: string | null, text: string, tone: BadgeTone): string =>
|
|
658
|
+
url ? badge(url, text, '', tone) : box(text, tone)
|
|
659
|
+
|
|
660
|
+
/** `📡 T-1 · In Progress` linked to the ticket, amber while new; a ticket without URL is a box no click opens. */
|
|
661
|
+
const ticketCell = ({ id, url, status, isNew }: TicketBadge): BadgeCell => {
|
|
662
|
+
const text = `${TICKET_MARK} ${id}${status ? `${STATUS_SEPARATOR}${status}` : ''}`
|
|
663
|
+
return { cell: linkedBox(url, text, ticketTone(isNew)), key: url ? chipKey(url) : null }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** The lane's ticket badges, then `+<n>` as plain text for the ones not shown. */
|
|
667
|
+
const ticketCells = (tickets: LaneTickets | null): BadgeCell[] => {
|
|
668
|
+
if (!tickets) return []
|
|
669
|
+
const more = tickets.more > 0 ? [{ cell: paint.dim(`+${tickets.more}`), key: null }] : []
|
|
670
|
+
return [...tickets.shown.map(ticketCell), ...more]
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const chipCellOf = (chip: LinkChip): BadgeCell => ({ cell: chipCell(chip), key: chipKey(chip.url) })
|
|
674
|
+
|
|
675
|
+
/** The chips of a plan behind a 📎, the plan chip first and the tickets beside it, then a blank line before the phases. */
|
|
676
|
+
const chipFrame = (chips: LinkChip[], tickets: LaneTickets | null, width: number): Frame => {
|
|
677
|
+
const isPlanChip = (chip: LinkChip) => planUrlOf(chipKey(chip.url)) !== null
|
|
678
|
+
const cells = [
|
|
679
|
+
...chips.filter(isPlanChip).map(chipCellOf),
|
|
680
|
+
...ticketCells(tickets),
|
|
681
|
+
...chips.filter((chip) => !isPlanChip(chip)).map(chipCellOf),
|
|
682
|
+
]
|
|
683
|
+
const frame = packedFrame(cells, CHIP_MARK, width)
|
|
588
684
|
return frame.lines.length > 0 ? { ...frame, lines: [...frame.lines, ''] } : frame
|
|
589
685
|
}
|
|
590
686
|
|
|
@@ -612,43 +708,22 @@ const badgeCells = (
|
|
|
612
708
|
const planHeading = (title: string, width: number): string[] => wrap(plain(title), width).map(paint.bold)
|
|
613
709
|
|
|
614
710
|
/**
|
|
615
|
-
* A plan as a node: its title in bold, and below it the links
|
|
616
|
-
*
|
|
711
|
+
* A plan as a node: its title in bold, and below it the links and the phases in plan order, each ticked one as a
|
|
712
|
+
* collapsed row of its own.
|
|
617
713
|
*/
|
|
618
714
|
const planBlock = (context: LaneContext, stepIndexes: number[], entries: PhaseEntry[]): Frame => {
|
|
619
|
-
const { progress,
|
|
715
|
+
const { progress, width, chips, tickets } = context
|
|
620
716
|
const { planTitle, planHref } = progress.steps[stepIndexes[0]].step
|
|
621
|
-
const shown = state.showDone ? entries : entries.filter((entry) => !foldsAway(progress, entry))
|
|
622
|
-
const ticked = state.showDone ? [] : entries.filter((entry) => foldsAway(progress, entry))
|
|
623
717
|
const inner = deeper(context)
|
|
624
718
|
return joinFrames([
|
|
625
719
|
textFrame(planHeading(planTitle, width)),
|
|
626
720
|
nest([
|
|
627
|
-
chipFrame(planHref ? (chips[planHref] ?? []) : [], inner.width),
|
|
628
|
-
...
|
|
629
|
-
textFrame(tickedLine(ticked)),
|
|
721
|
+
chipFrame(planHref ? (chips[planHref] ?? []) : [], tickets, inner.width),
|
|
722
|
+
...entries.map(({ stepIndex, phase }) => phaseFrame({ ...inner, stepIndex }, phase)),
|
|
630
723
|
]),
|
|
631
724
|
])
|
|
632
725
|
}
|
|
633
726
|
|
|
634
|
-
/**
|
|
635
|
-
* The summary line of a lane's notes is a row of its own, clickable like the key `n`; shown, the notes follow it
|
|
636
|
-
* unselectable.
|
|
637
|
-
*/
|
|
638
|
-
const noteFrame = (context: LaneContext, steps: PlanStep[]): Frame => {
|
|
639
|
-
if (steps.length === 0) return textFrame([])
|
|
640
|
-
const { progress, state, width } = context
|
|
641
|
-
const lane = progress.lane.name
|
|
642
|
-
const shown = state.notesShown.has(lane)
|
|
643
|
-
const summary = paint.dim(
|
|
644
|
-
`${chevronOf(shown)} ${shown ? TEXTS.notesShown(steps.length) : TEXTS.notesHidden(steps.length)}`,
|
|
645
|
-
)
|
|
646
|
-
const notes = shown
|
|
647
|
-
? steps.flatMap((step) => [...planHeading(step.planTitle, width), ...wrap(plain(step.label), width).map(paint.dim)])
|
|
648
|
-
: []
|
|
649
|
-
return rowFrame(notesKey(lane), [fit(summary, width)], notes, context)
|
|
650
|
-
}
|
|
651
|
-
|
|
652
727
|
/** The columns lane lines share, so names, statuses and bars of all of them line up. */
|
|
653
728
|
const STATUS_WIDTH = Math.max(...[...Object.values(AGENT_STATUS), NO_AGENT_WORD].map(textWidth))
|
|
654
729
|
|
|
@@ -674,8 +749,16 @@ const pullRequestMark = (pullRequest: PullRequest, badges: WatchBadges): string
|
|
|
674
749
|
return badge(pullRequest.url, text, '', markTone(found))
|
|
675
750
|
}
|
|
676
751
|
|
|
677
|
-
|
|
678
|
-
|
|
752
|
+
/** `📡 <n>` in amber while tickets of the lane are new, linked to the first of them; nothing otherwise. */
|
|
753
|
+
const ticketMark = (tickets: LaneTickets | undefined): string[] => {
|
|
754
|
+
const first = tickets?.shown.find(({ isNew }) => isNew)
|
|
755
|
+
if (!tickets || !first) return []
|
|
756
|
+
return [linkedBox(first.url, `${TICKET_MARK} ${tickets.newCount}`, BADGE_TONE.amber)]
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** A folded lane's marks: its pull request badges, and the ticket mark while a ticket is new. */
|
|
760
|
+
const laneMarks = ({ pullRequests, badges, tickets }: LaneView): string =>
|
|
761
|
+
[...pullRequests.map((pullRequest) => pullRequestMark(pullRequest, badges ?? {})), ...ticketMark(tickets)].join(' ')
|
|
679
762
|
|
|
680
763
|
/** What follows the status on a lane line: git state while the lane is open, the next phase when folded. */
|
|
681
764
|
const laneDetail = (view: LaneView, open: boolean): string => {
|
|
@@ -702,10 +785,10 @@ const laneRight = (view: LaneView): string => {
|
|
|
702
785
|
return counts ? `${contextCell(view.context)}${counts}` : contextCell(view.context).trimEnd()
|
|
703
786
|
}
|
|
704
787
|
|
|
705
|
-
/** A folded lane's
|
|
788
|
+
/** A folded lane's marks in the column of `columns.marks`, right before context and bar (D96). */
|
|
706
789
|
const markedRight = (view: LaneView, columns: LaneColumns): string => {
|
|
707
790
|
const right = laneRight(view)
|
|
708
|
-
const marks =
|
|
791
|
+
const marks = laneMarks(view)
|
|
709
792
|
if (!marks) return right
|
|
710
793
|
if (columns.right === 0) return padVisible(marks, columns.marks)
|
|
711
794
|
const pad = ' '.repeat(Math.max(columns.right - visibleLength(right), 0))
|
|
@@ -731,7 +814,16 @@ const laneBlock = (
|
|
|
731
814
|
columns: LaneColumns,
|
|
732
815
|
badgeTexts: BadgeTexts,
|
|
733
816
|
): Frame => {
|
|
734
|
-
const context = {
|
|
817
|
+
const context = {
|
|
818
|
+
progress: view.progress,
|
|
819
|
+
state,
|
|
820
|
+
width,
|
|
821
|
+
chips: view.chips,
|
|
822
|
+
badges: view.badges ?? {},
|
|
823
|
+
badgeTexts,
|
|
824
|
+
tickets: view.tickets ?? null,
|
|
825
|
+
models: view.models ?? {},
|
|
826
|
+
}
|
|
735
827
|
const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
|
|
736
828
|
const row = rowFrame(laneKey(view.progress.lane.name), laneLines(view, open, width, columns), [], context)
|
|
737
829
|
if (!open) return row
|
|
@@ -742,13 +834,12 @@ const laneBlock = (
|
|
|
742
834
|
),
|
|
743
835
|
}))
|
|
744
836
|
const planned = groups.filter(({ entries }) => entries.length > 0)
|
|
745
|
-
const notes = groups
|
|
746
|
-
.filter(({ entries }) => entries.length === 0)
|
|
747
|
-
.map(({ stepIndexes }) => view.progress.steps[stepIndexes[0]].step)
|
|
748
837
|
const inner = deeper(context)
|
|
749
838
|
const content = nestSpaced([
|
|
750
|
-
...planned.
|
|
751
|
-
|
|
839
|
+
...(planned.length === 0 ? [chipFrame([], inner.tickets, inner.width)] : []),
|
|
840
|
+
...planned.map(({ stepIndexes, entries }, index) =>
|
|
841
|
+
planBlock(index === 0 ? inner : { ...inner, tickets: null }, stepIndexes, entries),
|
|
842
|
+
),
|
|
752
843
|
followingFrame(view, state, inner.width),
|
|
753
844
|
])
|
|
754
845
|
return joinFrames([row, ...view.pullRequests.map((pullRequest) => pullRequestFrame(pullRequest, context)), content])
|
|
@@ -833,17 +924,20 @@ const separate = (blocks: Frame[], width: number): Frame[] =>
|
|
|
833
924
|
|
|
834
925
|
/**
|
|
835
926
|
* Why badges may be missing, said beside the clock: no watcher runs, or its state is stale. Nothing while it is live,
|
|
836
|
-
* or when no lane on screen has a pull request a badge could belong to
|
|
927
|
+
* or when no lane on screen has a pull request a badge could belong to and no source is marked `ticket`.
|
|
837
928
|
*/
|
|
838
|
-
const watchNotice = (
|
|
929
|
+
const watchNotice = (
|
|
930
|
+
{ lanes, watch, watchesTickets }: Pick<OverviewView, 'lanes' | 'watch' | 'watchesTickets'>,
|
|
931
|
+
texts: BadgeTexts,
|
|
932
|
+
): string => {
|
|
839
933
|
if (!watch || watch.kind === WATCH_HEALTH.live) return ''
|
|
840
|
-
if (!lanes.some(({ pullRequests }) => pullRequests.length > 0)) return ''
|
|
934
|
+
if (!watchesTickets && !lanes.some(({ pullRequests }) => pullRequests.length > 0)) return ''
|
|
841
935
|
return watch.kind === WATCH_HEALTH.off ? texts.watcherOff : texts.stale(watch.minutes)
|
|
842
936
|
}
|
|
843
937
|
|
|
844
938
|
const laneColumnsOf = (lanes: LaneView[]): LaneColumns => ({
|
|
845
939
|
name: Math.min(Math.max(0, ...lanes.map(({ progress }) => textWidth(progress.lane.name))), NAME_WIDTH_MAX),
|
|
846
|
-
marks: Math.max(0, ...lanes.map((lane) => visibleLength(
|
|
940
|
+
marks: Math.max(0, ...lanes.map((lane) => visibleLength(laneMarks(lane)))),
|
|
847
941
|
right: Math.max(0, ...lanes.map((lane) => visibleLength(laneRight(lane)))),
|
|
848
942
|
})
|
|
849
943
|
|
|
@@ -864,7 +958,7 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
|
|
|
864
958
|
* lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
|
|
865
959
|
*/
|
|
866
960
|
export const renderScreen = (
|
|
867
|
-
{ title, time, lanes, planned, watch, language = PLAN_LANGUAGE.en }: OverviewView,
|
|
961
|
+
{ title, time, lanes, planned, watch, watchesTickets, language = PLAN_LANGUAGE.en }: OverviewView,
|
|
868
962
|
state: Selection,
|
|
869
963
|
width: number,
|
|
870
964
|
): Screen => {
|
|
@@ -873,10 +967,10 @@ export const renderScreen = (
|
|
|
873
967
|
const columns = laneColumnsOf(active)
|
|
874
968
|
const badgeTexts = BADGE_TEXTS[language]
|
|
875
969
|
const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
|
|
876
|
-
const notice = watchNotice(active, watch, badgeTexts)
|
|
970
|
+
const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
|
|
877
971
|
const clock = notice ? `${paint.yellow(notice)}${COLUMN_SEPARATOR}${paint.dim(time)}` : paint.dim(time)
|
|
878
972
|
return {
|
|
879
|
-
header: [spread(paint.bold(title)
|
|
973
|
+
header: [spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width), rule(width)],
|
|
880
974
|
body: joinFrames([
|
|
881
975
|
...separate(blocks, width),
|
|
882
976
|
...(parked.length > 0 ? [textFrame([rule(width), ...parked])] : []),
|
package/src/plans/live.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname } from 'node:path'
|
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
4
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
5
|
import { openInBrowser } from '../terminal/open'
|
|
6
|
+
import type { LaneMemory } from './current-phase'
|
|
6
7
|
import { errorText } from './error-text'
|
|
7
8
|
import {
|
|
8
9
|
type FetchState,
|
|
@@ -50,6 +51,8 @@ type Live = {
|
|
|
50
51
|
/** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
|
|
51
52
|
fetchHead: string | null
|
|
52
53
|
reported: Map<string, string | null>
|
|
54
|
+
/** Per lane, the ticks seen at the last read and the phase that moved last; the herd's start empties it. */
|
|
55
|
+
memories: Map<string, LaneMemory>
|
|
53
56
|
pending: Timer | null
|
|
54
57
|
/** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
|
|
55
58
|
read: Snapshot | null
|
|
@@ -185,7 +188,7 @@ const refresh = (live: Live, onChange: () => void): Promise<void> => {
|
|
|
185
188
|
|
|
186
189
|
const readOnce = async (live: Live, onChange: () => void) => {
|
|
187
190
|
try {
|
|
188
|
-
const read = await loadSnapshot(live.source, live.pullRequests)
|
|
191
|
+
const read = await loadSnapshot(live.source, live.pullRequests, live.memories)
|
|
189
192
|
const next = withKnownPage(live, read)
|
|
190
193
|
live.read = read
|
|
191
194
|
if (!live.snapshot) live.view = { ...live.view, selection: startSelection(next, live.source.options) }
|
|
@@ -277,6 +280,7 @@ export const runLive = (source: Source): void => {
|
|
|
277
280
|
watchers: new Map(),
|
|
278
281
|
fetchHead: fetchHeadPath(source.root),
|
|
279
282
|
reported: new Map(),
|
|
283
|
+
memories: new Map(),
|
|
280
284
|
pending: null,
|
|
281
285
|
read: null,
|
|
282
286
|
page: null,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isPhaseModelRecord, type PhaseModelRecord } from './phase-models'
|
|
4
|
+
|
|
5
|
+
/** The file name the hook gives a record: 16 hex characters of a hash over plan and phase. */
|
|
6
|
+
const RECORD_FILE = /^[0-9a-f]{16}\.json$/
|
|
7
|
+
|
|
8
|
+
/** A record is a line of JSON; anything larger was not written by the hook. */
|
|
9
|
+
const MAX_RECORD_BYTES = 4 * 1024
|
|
10
|
+
|
|
11
|
+
/** A plain file of record size: a link could read elsewhere and a pipe would block the herd, so neither is read. */
|
|
12
|
+
const isRecordFile = (path: string): boolean => {
|
|
13
|
+
const entry = lstatSync(path)
|
|
14
|
+
return entry.isFile() && entry.size < MAX_RECORD_BYTES
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const recordOf = (path: string): PhaseModelRecord[] => {
|
|
18
|
+
try {
|
|
19
|
+
if (!isRecordFile(path)) return []
|
|
20
|
+
const record: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
21
|
+
return isPhaseModelRecord(record) ? [record] : []
|
|
22
|
+
} catch {
|
|
23
|
+
return []
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Every record of the model log in the main checkout; nothing when the directory is missing or unreadable. */
|
|
28
|
+
export const readPhaseModels = (root: string): PhaseModelRecord[] => {
|
|
29
|
+
const directory = join(root, '.jackherd', 'models')
|
|
30
|
+
try {
|
|
31
|
+
return readdirSync(directory)
|
|
32
|
+
.filter((name) => RECORD_FILE.test(name))
|
|
33
|
+
.flatMap((name) => recordOf(join(directory, name)))
|
|
34
|
+
} catch {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type LaneProgress, resolvePlanPath } from '../plan-format'
|
|
2
|
+
import { isRecord } from '../watch/json-guards'
|
|
3
|
+
|
|
4
|
+
/** The model families a phase ran on and was recommended, as lower-case words; `fits` null without a recommendation. */
|
|
5
|
+
export type PhaseModel = { used: string; recommended: string | null; fits: boolean | null }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A record as the kit's model log hook writes it to `.jackherd/models/<key>.json` in the main checkout: the plan from
|
|
9
|
+
* the repository root, the phase, the model id of the subagent's last answer and when it ended. Same shape as
|
|
10
|
+
* `PhaseModelRecord` in the hook, which this package does not import at run time.
|
|
11
|
+
*/
|
|
12
|
+
export type PhaseModelRecord = PhaseModel & { plan: string; phase: string; model: string; at: number }
|
|
13
|
+
|
|
14
|
+
/** A family as a word of at most 16 lower-case letters, so a record cannot widen the phase row. */
|
|
15
|
+
const FAMILY = /^[a-z]{1,16}$/
|
|
16
|
+
|
|
17
|
+
const isFamily = (value: unknown): value is string => typeof value === 'string' && FAMILY.test(value)
|
|
18
|
+
|
|
19
|
+
const isVerdict = (recommended: unknown, fits: unknown): boolean =>
|
|
20
|
+
(recommended === null && fits === null) || (isFamily(recommended) && typeof fits === 'boolean')
|
|
21
|
+
|
|
22
|
+
export const isPhaseModelRecord = (value: unknown): value is PhaseModelRecord =>
|
|
23
|
+
isRecord(value) &&
|
|
24
|
+
typeof value.plan === 'string' &&
|
|
25
|
+
typeof value.phase === 'string' &&
|
|
26
|
+
typeof value.model === 'string' &&
|
|
27
|
+
isFamily(value.used) &&
|
|
28
|
+
isVerdict(value.recommended, value.fits) &&
|
|
29
|
+
typeof value.at === 'number'
|
|
30
|
+
|
|
31
|
+
/** The key of a phase of a lane's step in `LaneView.models`. */
|
|
32
|
+
export const phaseModelKey = (stepIndex: number, phaseId: string): string => `${stepIndex}:${phaseId}`
|
|
33
|
+
|
|
34
|
+
const recordKey = (plan: string, phase: string): string => `${plan}\n${phase}`
|
|
35
|
+
|
|
36
|
+
/** The newest record per plan and phase. */
|
|
37
|
+
const newestByPhase = (records: readonly PhaseModelRecord[]): Map<string, PhaseModelRecord> => {
|
|
38
|
+
const newest = new Map<string, PhaseModelRecord>()
|
|
39
|
+
for (const record of records) {
|
|
40
|
+
const key = recordKey(record.plan, record.phase)
|
|
41
|
+
const known = newest.get(key)
|
|
42
|
+
if (!known || record.at > known.at) newest.set(key, record)
|
|
43
|
+
}
|
|
44
|
+
return newest
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The model of every phase of the lane's steps that has a record, by `phaseModelKey`. */
|
|
48
|
+
export const laneModelsOf = (
|
|
49
|
+
progress: LaneProgress,
|
|
50
|
+
records: readonly PhaseModelRecord[],
|
|
51
|
+
sprintDirectory: string,
|
|
52
|
+
): Record<string, PhaseModel> => {
|
|
53
|
+
const newest = newestByPhase(records)
|
|
54
|
+
return Object.fromEntries(
|
|
55
|
+
progress.steps.flatMap(({ step, phases }, stepIndex) => {
|
|
56
|
+
const plan = step.planHref ? resolvePlanPath(sprintDirectory, step.planHref) : null
|
|
57
|
+
return phases.flatMap((phase) => {
|
|
58
|
+
const record = plan === null ? undefined : newest.get(recordKey(plan, phase.id))
|
|
59
|
+
return record
|
|
60
|
+
? [
|
|
61
|
+
[
|
|
62
|
+
phaseModelKey(stepIndex, phase.id),
|
|
63
|
+
{ used: record.used, recommended: record.recommended, fits: record.fits },
|
|
64
|
+
],
|
|
65
|
+
]
|
|
66
|
+
: []
|
|
67
|
+
})
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
}
|