jackherd 1.13.0 → 1.15.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 +20 -0
- package/README.md +28 -38
- package/assets/icons/skills.svg +1 -0
- package/dist/config.d.ts +12 -1
- package/dist/config.js +35 -2
- package/dist/plan-format.d.ts +24 -1
- package/dist/plan-format.js +25 -5
- package/docs/plan-contract.md +84 -0
- package/docs/skills-dark.svg +15 -2
- package/docs/skills.svg +15 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/herdr-plugin/restore-plans.ts +56 -6
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
- package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
- package/kit/hooks/hooks/merge-guard/run.ts +6 -3
- package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
- package/kit/hooks/hooks/model-log/transcript.ts +4 -7
- package/kit/hooks/hooks/open-question/record.ts +4 -3
- package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
- package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
- package/kit/hooks/hooks/shared/transcript.ts +7 -4
- package/kit/hooks/hooks/task-hint/decision.ts +3 -2
- package/kit/hooks/hooks/task-hint/run.ts +2 -4
- package/kit/rules/rules/kit/pull-requests.md +1 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +24 -10
- package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
- package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
- package/kit/workflow/skills/orchestrate/references/events.md +6 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +7 -6
- package/kit/workflow/skills/start/SKILL.md +6 -2
- package/kit/workflow/skills/start/references/missing-commands.md +17 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +43 -0
- package/src/check/command.ts +53 -7
- package/src/check/file-kinds.ts +144 -0
- package/src/check/parallel-report.ts +10 -3
- package/src/check/plan.ts +118 -16
- package/src/check/report.ts +44 -1
- package/src/check/roots.ts +9 -0
- package/src/check/schema.ts +12 -0
- package/src/check/settings.ts +11 -3
- package/src/check/skill-budget-defaults.ts +21 -7
- package/src/check/skill-budgets.ts +49 -19
- package/src/claude/paths.ts +12 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +27 -0
- package/src/context/fill.ts +2 -1
- package/src/context/log-vocabulary.ts +22 -0
- package/src/context/session-log.ts +2 -1
- package/src/lane-prompt/build.ts +10 -5
- package/src/lane-prompt/command.ts +6 -1
- package/src/lane-prompt/input.ts +4 -4
- package/src/lane-prompt/texts.ts +18 -10
- package/src/plan-contract/command.ts +69 -0
- package/src/plan-contract/contract.ts +144 -0
- package/src/plan-format/dependencies.ts +1 -4
- package/src/plan-format/index.ts +4 -0
- package/src/plan-format/lanes.ts +11 -0
- package/src/plan-format/markdown.ts +6 -5
- package/src/plan-format/model-table.ts +27 -0
- package/src/plans/activity-read.ts +98 -0
- package/src/plans/ask-coordinator.ts +20 -11
- package/src/plans/lane-plans.ts +2 -1
- package/src/plans/layout.ts +49 -4
- package/src/plans/live.ts +17 -4
- package/src/plans/plan-panes.ts +25 -5
- package/src/plans/snapshot.ts +18 -8
- package/src/plans/subagents-read.ts +4 -3
- package/src/plans/subagents.ts +2 -1
- package/src/plans/texts.ts +27 -1
- package/src/settled/send.ts +80 -4
- package/src/shared/parse-json.ts +8 -0
- package/src/sync/command.ts +17 -10
- package/src/sync/foreign-skills.ts +40 -0
- package/src/sync/global-kit.ts +31 -21
- package/src/sync/init.ts +2 -2
- package/src/sync/kit-exclude.ts +39 -0
- package/src/sync/plan-file.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/report.ts +37 -2
- package/src/sync/sync-result.ts +5 -2
- package/src/sync/sync.ts +13 -5
- package/src/task-signals/claude-signals.ts +6 -5
- package/src/watch/command.ts +88 -18
- package/src/watch/lane-message.ts +1 -1
- package/src/watch/lane-progress.ts +57 -3
- package/src/watch/lane-report.ts +67 -2
- package/src/watch/lane-watch.ts +2 -8
- package/src/watch/plan-missing-read.ts +47 -0
- package/src/watch/plan-missing.ts +43 -0
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/task-parallel-touched.ts +9 -2
- package/src/watch/texts.ts +8 -0
- package/src/watch/watch-events.ts +7 -0
package/src/watch/command.ts
CHANGED
|
@@ -95,7 +95,14 @@ import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill
|
|
|
95
95
|
import { laneMessage, rememberedStatusLines, statusBlock } from './lane-message'
|
|
96
96
|
import { type LaneModelReading, laneModelEvents, recordLaneModels } from './lane-model'
|
|
97
97
|
import { laneParallelEvents, recordLaneParallels } from './lane-parallel'
|
|
98
|
-
import {
|
|
98
|
+
import {
|
|
99
|
+
boardCheckoutRows,
|
|
100
|
+
watchedLaneParallel,
|
|
101
|
+
watchedLaneProgress,
|
|
102
|
+
watchedPlanPullRequests,
|
|
103
|
+
watchedReadyPhases,
|
|
104
|
+
workedPhaseKeys,
|
|
105
|
+
} from './lane-progress'
|
|
99
106
|
import {
|
|
100
107
|
appointmentEventLine,
|
|
101
108
|
approvalDutyLines,
|
|
@@ -113,6 +120,9 @@ import {
|
|
|
113
120
|
noTaskSignalDutyLines,
|
|
114
121
|
otherPullRequestsLine,
|
|
115
122
|
parallelDutyLines,
|
|
123
|
+
planMissingDutyLines,
|
|
124
|
+
type ReadyPhase,
|
|
125
|
+
readyPhaseDutyLines,
|
|
116
126
|
sourceEventLines,
|
|
117
127
|
sourceFailingStatusLine,
|
|
118
128
|
watchEventLine,
|
|
@@ -136,6 +146,8 @@ import {
|
|
|
136
146
|
import { mainCheckoutDutyLine } from './main-checkout'
|
|
137
147
|
import { monitorToolAvailable } from './monitor-tool'
|
|
138
148
|
import { parseWatchOptions, type WatchOptions } from './options'
|
|
149
|
+
import type { PlanMissingEvent, PlanMissingState } from './plan-missing'
|
|
150
|
+
import { readPlanMissingEvents } from './plan-missing-read'
|
|
139
151
|
import {
|
|
140
152
|
ENSURE_TRIGGER,
|
|
141
153
|
type EnsureTrigger,
|
|
@@ -311,6 +323,8 @@ type Watching = {
|
|
|
311
323
|
laneStalls: StallState
|
|
312
324
|
/** The open artifact sends in their grace or already reported as `unlinked artifact`; persisted like the fingerprints. */
|
|
313
325
|
unlinkedArtifacts: UnlinkedState
|
|
326
|
+
/** Per working lane, the plan last reported as `plan missing on base`; persisted like the fingerprints. */
|
|
327
|
+
planMissing: PlanMissingState
|
|
314
328
|
/** The approval requests of the last poll; null before the first read, so the stored ids stay. */
|
|
315
329
|
approvalRequests: ApprovalRequestFile[] | null
|
|
316
330
|
/** The board's appointment table at the last poll and the time it was read at; null before the first read and while the board cannot be read. */
|
|
@@ -365,6 +379,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
|
|
|
365
379
|
noTaskSignals: watching.noTaskSignals,
|
|
366
380
|
laneStalls: watching.laneStalls,
|
|
367
381
|
unlinkedArtifacts: watching.unlinkedArtifacts,
|
|
382
|
+
planMissing: watching.planMissing,
|
|
368
383
|
approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
|
|
369
384
|
sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
|
|
370
385
|
appointments: watching.appointments
|
|
@@ -550,12 +565,14 @@ const laneReportOf = (
|
|
|
550
565
|
}
|
|
551
566
|
}
|
|
552
567
|
|
|
568
|
+
type BoardReadOrNote = { progress: Record<string, LaneProgress>; checkoutRows: number; notes: string[] }
|
|
569
|
+
|
|
553
570
|
/** `boardReadOf`, or when the board cannot be read, nothing of it and a note that says why. */
|
|
554
571
|
const boardReadOrNote = async (
|
|
555
572
|
watch: Watch,
|
|
556
573
|
lanes: readonly string[],
|
|
557
574
|
pullRequests: readonly WatchedPullRequest[],
|
|
558
|
-
): Promise<
|
|
575
|
+
): Promise<BoardReadOrNote> => {
|
|
559
576
|
try {
|
|
560
577
|
return { ...(await boardReadOf(watch, lanes, pullRequests)), notes: [] }
|
|
561
578
|
} catch (error) {
|
|
@@ -571,15 +588,15 @@ const boardReadOrNote = async (
|
|
|
571
588
|
* The lane lines of the status block, the coordinator's line, the line of the own pull requests without a lane that
|
|
572
589
|
* follows it (`others`), and the lines after the block (notes, a gh outage).
|
|
573
590
|
*/
|
|
574
|
-
const statusLinesOf =
|
|
591
|
+
const statusLinesOf = (
|
|
575
592
|
watch: Watch,
|
|
576
593
|
watching: Watching,
|
|
577
594
|
state: WatchState,
|
|
578
|
-
|
|
595
|
+
{ progress, checkoutRows, notes }: BoardReadOrNote,
|
|
596
|
+
): { laneLines: string[]; coordinator: string; others: string[]; rest: string[] } => {
|
|
579
597
|
const github = answered(watching.github)
|
|
580
598
|
const pullRequests = github?.pullRequests ?? []
|
|
581
599
|
const others = otherPullRequestsLine(pullRequests, github?.own ?? [], watch.texts)
|
|
582
|
-
const { progress, checkoutRows, notes } = await boardReadOrNote(watch, watching.lanes, pullRequests)
|
|
583
600
|
const closeLaneHint = hintFor(watch, REFERENCE.closeLane)
|
|
584
601
|
const laneLines = watching.lanes.map((lane) =>
|
|
585
602
|
laneReportLine(
|
|
@@ -642,6 +659,19 @@ const mainCheckoutLineAfter = async (
|
|
|
642
659
|
return mainCheckoutLineOf(watch)
|
|
643
660
|
}
|
|
644
661
|
|
|
662
|
+
/** The ready phases of the stopped lanes that rest (`done`, `idle`); only their plans are read. */
|
|
663
|
+
const readyPhasesOfResting = (
|
|
664
|
+
watch: Watch,
|
|
665
|
+
watching: Watching,
|
|
666
|
+
stopped: readonly string[],
|
|
667
|
+
progress: Readonly<Record<string, LaneProgress>>,
|
|
668
|
+
): Record<string, ReadyPhase[]> => {
|
|
669
|
+
const resting = stopped.filter((lane) => IDLE_STATUSES.has(watching.current[lane]?.status ?? ''))
|
|
670
|
+
if (resting.length === 0) return {}
|
|
671
|
+
const lanes = (readSprintLanes(watch) ?? []).filter((lane) => resting.includes(lane.name))
|
|
672
|
+
return watchedReadyPhases(watch.root, watch.settings, watch.sprintFile, lanes, workedPhaseKeys(progress))
|
|
673
|
+
}
|
|
674
|
+
|
|
645
675
|
/**
|
|
646
676
|
* Every line of one return, gathered before any is printed, so the output is never cut halfway by a slow lookup, and
|
|
647
677
|
* the state with what it showed: the report times of the stopped lanes and the status lines. A state without status
|
|
@@ -657,17 +687,21 @@ const reportOf = async (
|
|
|
657
687
|
const stops = stoppedLaneSections(watch, watching, stopped, next)
|
|
658
688
|
const eventLines = await eventLinesOf(watch, events, watching.github)
|
|
659
689
|
const mainCheckout = await mainCheckoutLineAfter(watch, watching, events)
|
|
690
|
+
const board = await boardReadOrNote(watch, watching.lanes, answered(watching.github)?.pullRequests ?? [])
|
|
691
|
+
const ready = readyPhasesOfResting(watch, watching, stopped, board.progress)
|
|
660
692
|
const dutyLines = [
|
|
661
693
|
...blockedDutyLines(stopped, watching.current, watch.texts),
|
|
694
|
+
...readyPhaseDutyLines(stopped, watching.current, ready, watch.texts),
|
|
662
695
|
...mergeDutyLines(events, watching.lanes, watch.texts),
|
|
663
696
|
...(mainCheckout === null ? [] : [mainCheckout]),
|
|
664
697
|
...laneFillDutyLines(events, watch.texts),
|
|
665
698
|
...laneModelDutyLines(events, watch.texts),
|
|
666
699
|
...parallelDutyLines(events, watch.texts),
|
|
667
700
|
...noTaskSignalDutyLines(events, watch.texts),
|
|
701
|
+
...planMissingDutyLines(events, watch.texts),
|
|
668
702
|
...approvalDutyLines(events, watch.texts),
|
|
669
703
|
]
|
|
670
|
-
const status =
|
|
704
|
+
const status = statusLinesOf(watch, watching, next, board)
|
|
671
705
|
const block = statusBlock(status.laneLines, status.coordinator, next.statusLines, status.others)
|
|
672
706
|
const shownReports = stopped.filter((lane) => stops.reportMtimes[lane] !== next.reportMtimes[lane])
|
|
673
707
|
return {
|
|
@@ -1129,6 +1163,27 @@ const unlinkedArtifactEventsOf = (watch: Watch, watching: Watching): UnlinkedArt
|
|
|
1129
1163
|
}
|
|
1130
1164
|
}
|
|
1131
1165
|
|
|
1166
|
+
/**
|
|
1167
|
+
* This heartbeat's `plan missing on base` events: the working lanes whose board row, as their worktree has it, links a
|
|
1168
|
+
* plan the base ref lacks, each once; records the state in `watching`. Nothing when the boards or git cannot be read:
|
|
1169
|
+
* the next heartbeat reads again.
|
|
1170
|
+
*/
|
|
1171
|
+
const planMissingEventsOf = (watch: Watch, watching: Watching): PlanMissingEvent[] => {
|
|
1172
|
+
try {
|
|
1173
|
+
const { events, state } = readPlanMissingEvents(
|
|
1174
|
+
watch.root,
|
|
1175
|
+
watch.settings,
|
|
1176
|
+
watch.sprintFile,
|
|
1177
|
+
workingLanes(watching),
|
|
1178
|
+
watching.planMissing,
|
|
1179
|
+
)
|
|
1180
|
+
watching.planMissing = state
|
|
1181
|
+
return events
|
|
1182
|
+
} catch {
|
|
1183
|
+
return []
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1132
1187
|
/** The progress of the working lanes, with the pull requests of the last GitHub read; nothing while no lane works. */
|
|
1133
1188
|
const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Record<string, LaneProgress>> => {
|
|
1134
1189
|
const working = workingLanes(watching)
|
|
@@ -1137,13 +1192,14 @@ const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Reco
|
|
|
1137
1192
|
return progress
|
|
1138
1193
|
}
|
|
1139
1194
|
|
|
1140
|
-
/** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts. */
|
|
1195
|
+
/** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts, the plans missing on the base. */
|
|
1141
1196
|
const heartbeatEventsOf = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
|
|
1142
1197
|
const progress = await workingProgressOf(watch, watching)
|
|
1143
1198
|
return [
|
|
1144
1199
|
...taskParallelEventsOf(watch, watching, now),
|
|
1145
1200
|
...noTaskSignalEventsOf(watch, watching, progress, now),
|
|
1146
1201
|
...unlinkedArtifactEventsOf(watch, watching),
|
|
1202
|
+
...planMissingEventsOf(watch, watching),
|
|
1147
1203
|
]
|
|
1148
1204
|
}
|
|
1149
1205
|
|
|
@@ -1206,6 +1262,7 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
|
|
|
1206
1262
|
...taskParallelEventsOf(watch, watching, now),
|
|
1207
1263
|
...noTaskSignalEventsOf(watch, watching, progress, now),
|
|
1208
1264
|
...unlinkedArtifactEventsOf(watch, watching),
|
|
1265
|
+
...planMissingEventsOf(watch, watching),
|
|
1209
1266
|
]
|
|
1210
1267
|
for (const event of events) console.log(`${watchEventLine(event, null)}${hintFor(watch, eventReference(event))}`)
|
|
1211
1268
|
return sent
|
|
@@ -1307,6 +1364,7 @@ const initialWatching = (
|
|
|
1307
1364
|
noTaskSignals: start.state.noTaskSignals,
|
|
1308
1365
|
laneStalls: start.state.laneStalls,
|
|
1309
1366
|
unlinkedArtifacts: start.state.unlinkedArtifacts,
|
|
1367
|
+
planMissing: start.state.planMissing,
|
|
1310
1368
|
approvalRequests: null,
|
|
1311
1369
|
appointments: null,
|
|
1312
1370
|
reportQuestions: null,
|
|
@@ -1319,7 +1377,8 @@ const saveState = (watch: Watch, watching: Watching) => {
|
|
|
1319
1377
|
writePrivateFile(watch.files.state, JSON.stringify(unread ? watching.state : recordedOf(watch, watching)))
|
|
1320
1378
|
}
|
|
1321
1379
|
|
|
1322
|
-
|
|
1380
|
+
/** Watches until the lanes end or the kit changes on disk; true when a kit update ended it. */
|
|
1381
|
+
const watchLanes = async (watch: Watch): Promise<boolean> => {
|
|
1323
1382
|
const takeOver = await takeOverLock(watch.files.lock)
|
|
1324
1383
|
const state = await readState(watch.files)
|
|
1325
1384
|
const agents = listAgents()
|
|
@@ -1358,8 +1417,9 @@ const watchLanes = async (watch: Watch) => {
|
|
|
1358
1417
|
await Bun.sleep(POLL_MS)
|
|
1359
1418
|
pollLanes(watch, watching)
|
|
1360
1419
|
await announceUpdate?.()
|
|
1361
|
-
if (endedByKitUpdate(kit, effects)) return
|
|
1420
|
+
if (endedByKitUpdate(kit, effects)) return true
|
|
1362
1421
|
}
|
|
1422
|
+
return false
|
|
1363
1423
|
} finally {
|
|
1364
1424
|
sourceChanges?.close()
|
|
1365
1425
|
stopPublisher?.()
|
|
@@ -1425,6 +1485,20 @@ const watchOf = (options: WatchOptions): Watch => {
|
|
|
1425
1485
|
}
|
|
1426
1486
|
}
|
|
1427
1487
|
|
|
1488
|
+
/** How a watch ended: the kit's source changed under it, or the loop stopped with an exit code. */
|
|
1489
|
+
type WatchEnd = { kind: 'kitUpdated' } | { kind: 'stopped'; code: number }
|
|
1490
|
+
|
|
1491
|
+
const watchToEnd = async (watch: Watch): Promise<WatchEnd> => {
|
|
1492
|
+
try {
|
|
1493
|
+
return (await watchLanes(watch)) ? { kind: 'kitUpdated' } : { kind: 'stopped', code: 0 }
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
console.log(`watcher stopped: ${errorText(error)}`)
|
|
1496
|
+
return { kind: 'stopped', code: 1 }
|
|
1497
|
+
} finally {
|
|
1498
|
+
releaseLock(watch.files.lock)
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1428
1502
|
export const run = async (args: string[]): Promise<number> => {
|
|
1429
1503
|
let options: WatchOptions
|
|
1430
1504
|
try {
|
|
@@ -1449,13 +1523,9 @@ export const run = async (args: string[]): Promise<number> => {
|
|
|
1449
1523
|
console.error(errorText(error))
|
|
1450
1524
|
return 1
|
|
1451
1525
|
}
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
return 1
|
|
1458
|
-
} finally {
|
|
1459
|
-
releaseLock(watch.files.lock)
|
|
1460
|
-
}
|
|
1526
|
+
const end = await watchToEnd(watch)
|
|
1527
|
+
// The loop's timers and child handles outlive it, so the process would stay beside the watcher that replaces it;
|
|
1528
|
+
// the state is saved and the lock released, as on SIGTERM.
|
|
1529
|
+
if (end.kind === 'kitUpdated') process.exit(0)
|
|
1530
|
+
return end.code
|
|
1461
1531
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
-
import { AGENT_STATUS } from '
|
|
2
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
3
3
|
import { budgetQuestions, questionCount, questionSections } from './report-questions'
|
|
4
4
|
|
|
5
5
|
/** A lane's report file as the watcher found it: its path, when it was last written, its text. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
|
-
import { type LaneProgress, parallelGroupsOfPlan, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
3
|
+
import { currentPhase, type LaneProgress, parallelGroupsOfPlan, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
4
4
|
import { boardRowsOf } from '../plans/board-rows'
|
|
5
5
|
import { type BaseReader, baseReaderOf, gitStatusOf } from '../plans/git'
|
|
6
6
|
import { laneWorkOf, readPlans, textAtBase } from '../plans/lane-plans'
|
|
@@ -8,6 +8,7 @@ import { laneHeads, type PlanPullRequest, planPullRequests } from '../plans/plan
|
|
|
8
8
|
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
|
|
9
9
|
import { workedLaneProgress } from '../plans/worked-phases'
|
|
10
10
|
import { parallelBatchOf } from './lane-parallel'
|
|
11
|
+
import { type ReadyPhase, readyPhasesOf } from './lane-report'
|
|
11
12
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
12
13
|
|
|
13
14
|
/** The open pull requests of the watcher's list that change a plan, as the herd chooses them. */
|
|
@@ -47,9 +48,18 @@ export const watchedLaneProgress = async (
|
|
|
47
48
|
return workedLaneProgress(lane, plans)
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/** The plan link of a lane's newest row that links one, as the board spells it; null without such a row. */
|
|
52
|
+
const newestPlanHref = (lane: SprintLane): string | null =>
|
|
53
|
+
lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref ?? null
|
|
54
|
+
|
|
50
55
|
/** The repository path of the plan of a lane's newest row that links one; null without such a row. */
|
|
51
|
-
const lanePlanPath = (
|
|
52
|
-
|
|
56
|
+
export const lanePlanPath = (
|
|
57
|
+
root: string,
|
|
58
|
+
settings: PlansSettings,
|
|
59
|
+
boardFile: string,
|
|
60
|
+
lane: SprintLane,
|
|
61
|
+
): string | null => {
|
|
62
|
+
const href = newestPlanHref(lane)
|
|
53
63
|
return href ? resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href) : null
|
|
54
64
|
}
|
|
55
65
|
|
|
@@ -97,6 +107,50 @@ export const watchedLaneParallel = (
|
|
|
97
107
|
return groups === null ? [] : parallelBatchOf(groups)
|
|
98
108
|
}
|
|
99
109
|
|
|
110
|
+
/** A phase the lanes work on, keyed by its plan link as the board spells it, since two plans may share an id. */
|
|
111
|
+
const workedPhaseKey = (planHref: string, id: string): string => `${planHref}#${id}`
|
|
112
|
+
|
|
113
|
+
/** The ids of the phases in the plan of `planHref` that a lane works on, out of `workedPhaseKeys`. */
|
|
114
|
+
const phaseIdsWorkedIn = (worked: ReadonlySet<string>, planHref: string): Set<string> => {
|
|
115
|
+
const prefix = workedPhaseKey(planHref, '')
|
|
116
|
+
return new Set([...worked].flatMap((key) => (key.startsWith(prefix) ? [key.slice(prefix.length)] : [])))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The phases the lanes work on: the current phase of each lane's progress that has one, with its plan link. */
|
|
120
|
+
export const workedPhaseKeys = (progress: Readonly<Record<string, LaneProgress>>): Set<string> =>
|
|
121
|
+
new Set(
|
|
122
|
+
Object.values(progress).flatMap((each) => {
|
|
123
|
+
const phase = currentPhase(each)
|
|
124
|
+
const planHref = each.current === null ? null : each.steps[each.current.stepIndex]?.step.planHref
|
|
125
|
+
return phase && planHref ? [workedPhaseKey(planHref, phase.id)] : []
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Per lane, the open phases of its plan whose dependencies are all ticked off and that no lane works on (`worked`, from
|
|
131
|
+
* `workedPhaseKeys`), by the plan's dependency table and ticks, as the lane's worktree has them; empty for a lane
|
|
132
|
+
* without a readable plan.
|
|
133
|
+
*/
|
|
134
|
+
export const watchedReadyPhases = (
|
|
135
|
+
root: string,
|
|
136
|
+
settings: PlansSettings,
|
|
137
|
+
boardFile: string,
|
|
138
|
+
lanes: readonly SprintLane[],
|
|
139
|
+
worked: ReadonlySet<string>,
|
|
140
|
+
): Record<string, ReadyPhase[]> =>
|
|
141
|
+
Object.fromEntries(
|
|
142
|
+
lanes.map((lane) => {
|
|
143
|
+
const planHref = newestPlanHref(lane)
|
|
144
|
+
const text = watchedLanePlanText(root, settings, boardFile, lane)
|
|
145
|
+
return [
|
|
146
|
+
lane.name,
|
|
147
|
+
text === null || planHref === null
|
|
148
|
+
? []
|
|
149
|
+
: readyPhasesOf(text, settings.planLanguage, phaseIdsWorkedIn(worked, planHref)),
|
|
150
|
+
]
|
|
151
|
+
}),
|
|
152
|
+
)
|
|
153
|
+
|
|
100
154
|
/**
|
|
101
155
|
* The rows the checkout's board file (`checkoutText`) has beyond the board at the base ref, counted as the herd
|
|
102
156
|
* adds them; 0 when the base ref lacks the board, which is then read from the checkout as a whole.
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { CONTEXT_LEVEL, type ContextLevel, contextLabel } from '../context'
|
|
2
2
|
import { closeDryRunOf } from '../lane/close-command-line'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
currentPhase,
|
|
5
|
+
isPhaseFinished,
|
|
6
|
+
isPhaseOpen,
|
|
7
|
+
isTickedUnmerged,
|
|
8
|
+
type LaneProgress,
|
|
9
|
+
parseDependencyTable,
|
|
10
|
+
parsePlanPhases,
|
|
11
|
+
phaseLabel,
|
|
12
|
+
phaseLabels,
|
|
13
|
+
phasesText,
|
|
14
|
+
} from '../plan-format'
|
|
4
15
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
16
|
import { CHECK_NAME_LENGTH, type ChecksState } from '../plans/pull-requests'
|
|
6
17
|
import { shownWithMore } from '../plans/shown-with-more'
|
|
18
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
19
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
7
20
|
import type { AppointmentEvent } from './appointments'
|
|
8
21
|
import type { ApprovalEvent } from './approvals'
|
|
9
22
|
import type { CoordinatorRead } from './coordinator-fill'
|
|
@@ -12,7 +25,8 @@ import type { LaneFillEvent } from './lane-fill'
|
|
|
12
25
|
import type { LaneModelEvent } from './lane-model'
|
|
13
26
|
import type { LaneParallelEvent } from './lane-parallel'
|
|
14
27
|
import { laneStalledLine } from './lane-stalled'
|
|
15
|
-
import {
|
|
28
|
+
import { type LaneSnapshot, NO_AGENT } from './lane-watch'
|
|
29
|
+
import { PLAN_MISSING_EVENT, type PlanMissingEvent, planMissingLine } from './plan-missing'
|
|
16
30
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
17
31
|
import { SOURCE_FIELD } from './source-fields'
|
|
18
32
|
import { type FieldChange, lineText, type SourceEvent } from './sources'
|
|
@@ -161,6 +175,7 @@ const laneEventLine = (event: WatchEvent): string | null => {
|
|
|
161
175
|
return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
|
|
162
176
|
if (event.kind === WATCH_EVENT.noTaskSignal) return `=== ${terminalSafe(noTaskSignalLine(event))}`
|
|
163
177
|
if (event.kind === WATCH_EVENT.laneStalled) return `=== ${terminalSafe(laneStalledLine(event))}`
|
|
178
|
+
if (event.kind === WATCH_EVENT.planMissing) return `=== ${planMissingLine(event)}`
|
|
164
179
|
return null
|
|
165
180
|
}
|
|
166
181
|
|
|
@@ -339,6 +354,14 @@ export const noTaskSignalDutyLines = (events: readonly WatchEvent[], texts: Watc
|
|
|
339
354
|
.filter(isNoTaskSignal)
|
|
340
355
|
.map(({ lane }) => `${terminalSafe(lane)} ${NO_TASK_SIGNAL_EVENT}: ${texts.noTaskSignalDuty}`)
|
|
341
356
|
|
|
357
|
+
const isPlanMissing = (event: WatchEvent): event is PlanMissingEvent => event.kind === WATCH_EVENT.planMissing
|
|
358
|
+
|
|
359
|
+
/** What a lane whose plan only its worktree has asks of the coordinator (`orchestrate` § 2 step 0): one line per such lane. */
|
|
360
|
+
export const planMissingDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
|
|
361
|
+
events
|
|
362
|
+
.filter(isPlanMissing)
|
|
363
|
+
.map(({ lane }) => `${terminalSafe(lane)} ${PLAN_MISSING_EVENT}: ${texts.planMissingDuty}`)
|
|
364
|
+
|
|
342
365
|
const isApproval = (event: WatchEvent): event is ApprovalEvent => event.kind === WATCH_EVENT.approval
|
|
343
366
|
|
|
344
367
|
/**
|
|
@@ -360,6 +383,48 @@ export const blockedDutyLines = (stopped: readonly string[], current: LaneSnapsh
|
|
|
360
383
|
.filter((lane) => current[lane]?.status === AGENT_STATUS.blocked)
|
|
361
384
|
.map((lane) => `${terminalSafe(lane)} ${AGENT_STATUS.blocked}: ${texts.blockedDuty}`)
|
|
362
385
|
|
|
386
|
+
/** An open phase whose dependencies are all ticked off, with the phases it waited for. */
|
|
387
|
+
export type ReadyPhase = { id: string; after: string[] }
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The phases of a plan that can start now, in table order: open, not worked on (`worked`, the ids of the phases the
|
|
391
|
+
* lanes work on), every phase of the `Depends on` cell finished and no external gate in it. Empty for a plan without
|
|
392
|
+
* a dependency table or a row without a phase in the plan.
|
|
393
|
+
*/
|
|
394
|
+
export const readyPhasesOf = (markdown: string, language: PlanLanguage, worked: ReadonlySet<string>): ReadyPhase[] => {
|
|
395
|
+
const table = parseDependencyTable(markdown, language) ?? []
|
|
396
|
+
const phases = parsePlanPhases(markdown, language)
|
|
397
|
+
const finished = new Set(phases.filter(isPhaseFinished).map(({ id }) => id))
|
|
398
|
+
const open = new Set(phases.filter(isPhaseOpen).map(({ id }) => id))
|
|
399
|
+
return table
|
|
400
|
+
.filter(
|
|
401
|
+
({ id, after, external }) =>
|
|
402
|
+
open.has(id) && !worked.has(id) && external.length === 0 && after.every((each) => finished.has(each)),
|
|
403
|
+
)
|
|
404
|
+
.map(({ id, after }) => ({ id, after }))
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const readyPhaseText = ({ id, after }: ReadyPhase): string =>
|
|
408
|
+
`${phaseLabel(id)}${after.length > 0 ? ` (after ${phaseLabels(after, ', ')})` : ''}`
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* What a resting lane (`done` or `idle`) with ready phases asks of the coordinator (`orchestrate` § 2): one line per such
|
|
412
|
+
* lane, naming every ready phase, to start it now; a pull request waiting for approval holds only the commit.
|
|
413
|
+
*/
|
|
414
|
+
export const readyPhaseDutyLines = (
|
|
415
|
+
stopped: readonly string[],
|
|
416
|
+
current: LaneSnapshot,
|
|
417
|
+
ready: Readonly<Record<string, readonly ReadyPhase[]>>,
|
|
418
|
+
texts: WatchTexts,
|
|
419
|
+
): string[] =>
|
|
420
|
+
stopped.flatMap((lane) => {
|
|
421
|
+
const phases = ready[lane] ?? []
|
|
422
|
+
if (phases.length === 0 || !RESTING_STATUSES.has(current[lane]?.status ?? '')) return []
|
|
423
|
+
return [
|
|
424
|
+
`${terminalSafe(lane)} ready: ${phases.map(readyPhaseText).join(', ')}: ${texts.readyPhaseDuty(phases.length)}`,
|
|
425
|
+
]
|
|
426
|
+
})
|
|
427
|
+
|
|
363
428
|
const takeOverNote = ({ replaced, stillRunning }: TakeOver): string => {
|
|
364
429
|
if (stillRunning !== null) return ` (watcher ${stillRunning} did not stop)`
|
|
365
430
|
return replaced === null ? '' : ` (replaced watcher ${replaced})`
|
package/src/watch/lane-watch.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
export const AGENT_STATUS = { working: 'working', blocked: 'blocked', done: 'done', idle: 'idle' } as const
|
|
3
|
+
export type HerdrAgent = { name?: string; pane_id: string; agent_status: string; state_change_seq?: number }
|
|
5
4
|
|
|
6
5
|
/** The state a report gives a lane whose agent left the herdr listing. */
|
|
7
6
|
export const LANE_GONE = 'no longer listed'
|
|
@@ -9,8 +8,6 @@ export const LANE_GONE = 'no longer listed'
|
|
|
9
8
|
/** The state a report gives a lane that has no agent in herdr. */
|
|
10
9
|
export const NO_AGENT = 'no agent'
|
|
11
10
|
|
|
12
|
-
export type AgentStatus = (typeof AGENT_STATUS)[keyof typeof AGENT_STATUS]
|
|
13
|
-
|
|
14
11
|
export type LaneState = { status: string; seq: number | null }
|
|
15
12
|
|
|
16
13
|
export type LaneSnapshot = Record<string, LaneState>
|
|
@@ -20,9 +17,6 @@ export type SeenSeqs = Record<string, number | null>
|
|
|
20
17
|
|
|
21
18
|
const STOP_STATUSES = new Set<string>([AGENT_STATUS.blocked, AGENT_STATUS.done, AGENT_STATUS.idle])
|
|
22
19
|
|
|
23
|
-
export const isAgentStatus = (value: string): value is AgentStatus =>
|
|
24
|
-
(Object.values(AGENT_STATUS) as string[]).includes(value)
|
|
25
|
-
|
|
26
20
|
/** The state of each named lane in a `herdr agent list` answer; unnamed and other agents are left out. */
|
|
27
21
|
export const toSnapshot = (agents: readonly HerdrAgent[], lanes: readonly string[]): LaneSnapshot =>
|
|
28
22
|
Object.fromEntries(
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { parseSprintLanes } from '../plan-format'
|
|
4
|
+
import { baseReaderAt, commitOf } from '../plans/git'
|
|
5
|
+
import { isOutsideRepository } from '../plans/lane-plans'
|
|
6
|
+
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
|
|
7
|
+
import { lanePlanPath } from './lane-progress'
|
|
8
|
+
import {
|
|
9
|
+
type PlanMissingEvent,
|
|
10
|
+
type PlanMissingReadings,
|
|
11
|
+
type PlanMissingState,
|
|
12
|
+
planMissingEvents,
|
|
13
|
+
} from './plan-missing'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The plan the lane's newest linking row names in the board as its worktree has it; null without a worktree board or
|
|
17
|
+
* row, or for a link that leaves the repository, which no base ref can hold.
|
|
18
|
+
*/
|
|
19
|
+
const worktreePlanOf = (root: string, settings: PlansSettings, boardFile: string, laneName: string): string | null => {
|
|
20
|
+
const file = join(worktreeOf(root, settings, laneName), boardRepoPathOf(root, settings, boardFile))
|
|
21
|
+
if (!existsSync(file)) return null
|
|
22
|
+
const lane = parseSprintLanes(readFileSync(file, 'utf8'), settings.planLanguage).find(({ name }) => name === laneName)
|
|
23
|
+
const path = lane === undefined ? null : lanePlanPath(root, settings, boardFile, lane)
|
|
24
|
+
return path === null || isOutsideRepository(path) ? null : path
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This heartbeat's `plan missing on base` events and the state to keep, for the working lanes: the plan of each lane's
|
|
29
|
+
* row in its own worktree against the base ref, where a worktree made from the base would not find it. Nothing when
|
|
30
|
+
* the base ref does not resolve, which would make every plan look missing.
|
|
31
|
+
*/
|
|
32
|
+
export const readPlanMissingEvents = (
|
|
33
|
+
root: string,
|
|
34
|
+
settings: PlansSettings,
|
|
35
|
+
boardFile: string,
|
|
36
|
+
workingLanes: readonly string[],
|
|
37
|
+
previous: PlanMissingState,
|
|
38
|
+
): { events: PlanMissingEvent[]; state: PlanMissingState } => {
|
|
39
|
+
const baseRef = baseRefOf(settings)
|
|
40
|
+
const commit = commitOf(root, baseRef)
|
|
41
|
+
if (commit === null) return { events: [], state: previous }
|
|
42
|
+
const base = baseReaderAt(root, commit)
|
|
43
|
+
const readings: PlanMissingReadings = Object.fromEntries(
|
|
44
|
+
workingLanes.map((lane) => [lane, worktreePlanOf(root, settings, boardFile, lane)]),
|
|
45
|
+
)
|
|
46
|
+
return planMissingEvents(previous, readings, (path) => base(path) !== null, baseRef)
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import { isRecord } from '../shared/is-record'
|
|
3
|
+
|
|
4
|
+
export const PLAN_MISSING_EVENT = 'plan missing on base'
|
|
5
|
+
|
|
6
|
+
/** Per working lane: the plan its newest board row links, as its worktree has it; null without one. */
|
|
7
|
+
export type PlanMissingReadings = Readonly<Record<string, string | null>>
|
|
8
|
+
|
|
9
|
+
/** Per lane: the plan path last reported as missing on the base ref; a lane whose plan is there has no entry. */
|
|
10
|
+
export type PlanMissingState = Record<string, string>
|
|
11
|
+
|
|
12
|
+
export type PlanMissingEvent = { kind: typeof PLAN_MISSING_EVENT; lane: string; path: string; baseRef: string }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One event per working lane whose board row, as its worktree has it, links a plan the base ref lacks (`existsAtBase`
|
|
16
|
+
* answers for a repository path). A plan is reported once while it stays missing: the state clears when it reaches the
|
|
17
|
+
* base or the row links another, and a lane without a reading (not at work) keeps what it had.
|
|
18
|
+
*/
|
|
19
|
+
export const planMissingEvents = (
|
|
20
|
+
previous: PlanMissingState,
|
|
21
|
+
readings: PlanMissingReadings,
|
|
22
|
+
existsAtBase: (path: string) => boolean,
|
|
23
|
+
baseRef: string,
|
|
24
|
+
): { events: PlanMissingEvent[]; state: PlanMissingState } => {
|
|
25
|
+
const events: PlanMissingEvent[] = []
|
|
26
|
+
const state: PlanMissingState = { ...previous }
|
|
27
|
+
for (const [lane, planPath] of Object.entries(readings)) {
|
|
28
|
+
if (planPath === null || existsAtBase(planPath)) {
|
|
29
|
+
delete state[lane]
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (previous[lane] !== planPath) events.push({ kind: PLAN_MISSING_EVENT, lane, path: planPath, baseRef })
|
|
33
|
+
state[lane] = planPath
|
|
34
|
+
}
|
|
35
|
+
return { events, state }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `lane <name> plan missing on <base ref>: <path>`. */
|
|
39
|
+
export const planMissingLine = ({ lane, path, baseRef }: PlanMissingEvent): string =>
|
|
40
|
+
`lane ${terminalSafe(lane)} plan missing on ${terminalSafe(baseRef)}: ${terminalSafe(path)}`
|
|
41
|
+
|
|
42
|
+
export const isPlanMissingState = (value: unknown): value is PlanMissingState =>
|
|
43
|
+
isRecord(value) && Object.values(value).every((path) => typeof path === 'string')
|
|
@@ -37,6 +37,7 @@ const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
|
|
|
37
37
|
[WATCH_EVENT.taskParallel]: REFERENCE.events,
|
|
38
38
|
[WATCH_EVENT.noTaskSignal]: REFERENCE.events,
|
|
39
39
|
[WATCH_EVENT.unlinkedArtifact]: REFERENCE.events,
|
|
40
|
+
[WATCH_EVENT.planMissing]: REFERENCE.events,
|
|
40
41
|
[UPDATE_AVAILABLE_EVENT]: REFERENCE.events,
|
|
41
42
|
[WATCH_EVENT.laneModel]: REFERENCE.laneSession,
|
|
42
43
|
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { isAbsolute, relative } from 'node:path'
|
|
2
|
+
import { CLAUDE_TOOL, CONTENT_BLOCK_TYPE } from '../context/log-vocabulary'
|
|
2
3
|
import { parseLogEntry } from '../context/session-log'
|
|
3
4
|
import { isRecord } from '../shared/is-record'
|
|
4
5
|
|
|
5
|
-
const WRITING_TOOLS: ReadonlySet<string> = new Set([
|
|
6
|
+
const WRITING_TOOLS: ReadonlySet<string> = new Set([
|
|
7
|
+
CLAUDE_TOOL.edit,
|
|
8
|
+
CLAUDE_TOOL.write,
|
|
9
|
+
CLAUDE_TOOL.multiEdit,
|
|
10
|
+
CLAUDE_TOOL.notebookEdit,
|
|
11
|
+
])
|
|
6
12
|
|
|
7
13
|
const writtenPathsOf = (entry: Record<string, unknown>): string[] => {
|
|
8
14
|
const content = isRecord(entry.message) && Array.isArray(entry.message.content) ? entry.message.content : []
|
|
9
15
|
return content.flatMap((block) => {
|
|
10
|
-
if (!isRecord(block) || block.type !==
|
|
16
|
+
if (!isRecord(block) || block.type !== CONTENT_BLOCK_TYPE.toolUse || !WRITING_TOOLS.has(String(block.name)))
|
|
17
|
+
return []
|
|
11
18
|
const input = isRecord(block.input) ? block.input : {}
|
|
12
19
|
const path = input.file_path ?? input.notebook_path
|
|
13
20
|
return typeof path === 'string' ? [path] : []
|
package/src/watch/texts.ts
CHANGED
|
@@ -28,8 +28,12 @@ export type WatchTexts = {
|
|
|
28
28
|
parallelDuty: (phases: string) => string
|
|
29
29
|
/** What a lane that works without a task signal asks of the coordinator. */
|
|
30
30
|
noTaskSignalDuty: string
|
|
31
|
+
/** What a lane whose plan the base ref lacks asks of the coordinator. */
|
|
32
|
+
planMissingDuty: string
|
|
31
33
|
/** What a lane's call waiting for approval asks of the coordinator, with the mark (request id and call hash) the question must carry. */
|
|
32
34
|
approvalDuty: (id: string, hash: string) => string
|
|
35
|
+
/** What a resting lane with ready phases asks of the coordinator, with how many phases are ready. */
|
|
36
|
+
readyPhaseDuty: (count: number) => string
|
|
33
37
|
/** The standing duty while the main checkout is behind its base branch on the remote (`origin/main`), with the count of commits. */
|
|
34
38
|
mainCheckoutBehind: (remoteBase: string, count: number) => string
|
|
35
39
|
/** The standing duty while the main checkout has another branch than the base checked out (null for a detached head). */
|
|
@@ -70,8 +74,12 @@ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
|
|
|
70
74
|
parallelDuty: (phases) =>
|
|
71
75
|
`${phases} can run in parallel: propose it to the user, check content and release files yourself (the table covers paths and markers only)`,
|
|
72
76
|
noTaskSignalDuty: 'remind the lane to report its task with jh task start; do not stop it',
|
|
77
|
+
planMissingDuty:
|
|
78
|
+
"the herd shows the base ref's plan, not this one: run § 2 step 0 for it, copying from the lane's worktree",
|
|
73
79
|
laneModelDuty:
|
|
74
80
|
'switch it while idle: /exit, then claude --resume <session> --model <id> in the same pane (§ 2 step 6)',
|
|
81
|
+
readyPhaseDuty: (count) =>
|
|
82
|
+
`start ${count === 1 ? 'it' : 'them'} now (§ 2); a waiting PR approval holds only the commit`,
|
|
75
83
|
mainCheckoutBehind: (remoteBase, count) =>
|
|
76
84
|
`main checkout behind ${remoteBase} by ${count} (git merge --ff-only ${remoteBase})`,
|
|
77
85
|
mainCheckoutOffBase: (branch, baseBranch) => `main checkout on ${branch ?? 'a detached head'}, not ${baseBranch}`,
|