jackherd 1.14.0 → 1.15.1
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 +27 -19
- package/assets/icons/skills.svg +1 -0
- package/dist/config.d.ts +9 -1
- package/dist/config.js +24 -1
- 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/decision.ts +3 -1
- 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 +22 -9
- 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/plan-commit.md +5 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +19 -0
- package/src/check/command.ts +1 -1
- package/src/check/file-kinds.ts +10 -8
- package/src/check/parallel-report.ts +10 -3
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +17 -5
- 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/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 +56 -17
- package/src/watch/lane-message.ts +1 -1
- package/src/watch/lane-progress.ts +51 -2
- package/src/watch/lane-report.ts +57 -2
- package/src/watch/lane-watch.ts +2 -8
- package/src/watch/task-parallel-touched.ts +9 -2
- package/src/watch/texts.ts +4 -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,
|
|
@@ -114,6 +121,8 @@ import {
|
|
|
114
121
|
otherPullRequestsLine,
|
|
115
122
|
parallelDutyLines,
|
|
116
123
|
planMissingDutyLines,
|
|
124
|
+
type ReadyPhase,
|
|
125
|
+
readyPhaseDutyLines,
|
|
117
126
|
sourceEventLines,
|
|
118
127
|
sourceFailingStatusLine,
|
|
119
128
|
watchEventLine,
|
|
@@ -556,12 +565,14 @@ const laneReportOf = (
|
|
|
556
565
|
}
|
|
557
566
|
}
|
|
558
567
|
|
|
568
|
+
type BoardReadOrNote = { progress: Record<string, LaneProgress>; checkoutRows: number; notes: string[] }
|
|
569
|
+
|
|
559
570
|
/** `boardReadOf`, or when the board cannot be read, nothing of it and a note that says why. */
|
|
560
571
|
const boardReadOrNote = async (
|
|
561
572
|
watch: Watch,
|
|
562
573
|
lanes: readonly string[],
|
|
563
574
|
pullRequests: readonly WatchedPullRequest[],
|
|
564
|
-
): Promise<
|
|
575
|
+
): Promise<BoardReadOrNote> => {
|
|
565
576
|
try {
|
|
566
577
|
return { ...(await boardReadOf(watch, lanes, pullRequests)), notes: [] }
|
|
567
578
|
} catch (error) {
|
|
@@ -577,15 +588,15 @@ const boardReadOrNote = async (
|
|
|
577
588
|
* The lane lines of the status block, the coordinator's line, the line of the own pull requests without a lane that
|
|
578
589
|
* follows it (`others`), and the lines after the block (notes, a gh outage).
|
|
579
590
|
*/
|
|
580
|
-
const statusLinesOf =
|
|
591
|
+
const statusLinesOf = (
|
|
581
592
|
watch: Watch,
|
|
582
593
|
watching: Watching,
|
|
583
594
|
state: WatchState,
|
|
584
|
-
|
|
595
|
+
{ progress, checkoutRows, notes }: BoardReadOrNote,
|
|
596
|
+
): { laneLines: string[]; coordinator: string; others: string[]; rest: string[] } => {
|
|
585
597
|
const github = answered(watching.github)
|
|
586
598
|
const pullRequests = github?.pullRequests ?? []
|
|
587
599
|
const others = otherPullRequestsLine(pullRequests, github?.own ?? [], watch.texts)
|
|
588
|
-
const { progress, checkoutRows, notes } = await boardReadOrNote(watch, watching.lanes, pullRequests)
|
|
589
600
|
const closeLaneHint = hintFor(watch, REFERENCE.closeLane)
|
|
590
601
|
const laneLines = watching.lanes.map((lane) =>
|
|
591
602
|
laneReportLine(
|
|
@@ -648,6 +659,19 @@ const mainCheckoutLineAfter = async (
|
|
|
648
659
|
return mainCheckoutLineOf(watch)
|
|
649
660
|
}
|
|
650
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
|
+
|
|
651
675
|
/**
|
|
652
676
|
* Every line of one return, gathered before any is printed, so the output is never cut halfway by a slow lookup, and
|
|
653
677
|
* the state with what it showed: the report times of the stopped lanes and the status lines. A state without status
|
|
@@ -663,8 +687,11 @@ const reportOf = async (
|
|
|
663
687
|
const stops = stoppedLaneSections(watch, watching, stopped, next)
|
|
664
688
|
const eventLines = await eventLinesOf(watch, events, watching.github)
|
|
665
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)
|
|
666
692
|
const dutyLines = [
|
|
667
693
|
...blockedDutyLines(stopped, watching.current, watch.texts),
|
|
694
|
+
...readyPhaseDutyLines(stopped, watching.current, ready, watch.texts),
|
|
668
695
|
...mergeDutyLines(events, watching.lanes, watch.texts),
|
|
669
696
|
...(mainCheckout === null ? [] : [mainCheckout]),
|
|
670
697
|
...laneFillDutyLines(events, watch.texts),
|
|
@@ -674,7 +701,7 @@ const reportOf = async (
|
|
|
674
701
|
...planMissingDutyLines(events, watch.texts),
|
|
675
702
|
...approvalDutyLines(events, watch.texts),
|
|
676
703
|
]
|
|
677
|
-
const status =
|
|
704
|
+
const status = statusLinesOf(watch, watching, next, board)
|
|
678
705
|
const block = statusBlock(status.laneLines, status.coordinator, next.statusLines, status.others)
|
|
679
706
|
const shownReports = stopped.filter((lane) => stops.reportMtimes[lane] !== next.reportMtimes[lane])
|
|
680
707
|
return {
|
|
@@ -1350,7 +1377,8 @@ const saveState = (watch: Watch, watching: Watching) => {
|
|
|
1350
1377
|
writePrivateFile(watch.files.state, JSON.stringify(unread ? watching.state : recordedOf(watch, watching)))
|
|
1351
1378
|
}
|
|
1352
1379
|
|
|
1353
|
-
|
|
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> => {
|
|
1354
1382
|
const takeOver = await takeOverLock(watch.files.lock)
|
|
1355
1383
|
const state = await readState(watch.files)
|
|
1356
1384
|
const agents = listAgents()
|
|
@@ -1389,8 +1417,9 @@ const watchLanes = async (watch: Watch) => {
|
|
|
1389
1417
|
await Bun.sleep(POLL_MS)
|
|
1390
1418
|
pollLanes(watch, watching)
|
|
1391
1419
|
await announceUpdate?.()
|
|
1392
|
-
if (endedByKitUpdate(kit, effects)) return
|
|
1420
|
+
if (endedByKitUpdate(kit, effects)) return true
|
|
1393
1421
|
}
|
|
1422
|
+
return false
|
|
1394
1423
|
} finally {
|
|
1395
1424
|
sourceChanges?.close()
|
|
1396
1425
|
stopPublisher?.()
|
|
@@ -1456,6 +1485,20 @@ const watchOf = (options: WatchOptions): Watch => {
|
|
|
1456
1485
|
}
|
|
1457
1486
|
}
|
|
1458
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
|
+
|
|
1459
1502
|
export const run = async (args: string[]): Promise<number> => {
|
|
1460
1503
|
let options: WatchOptions
|
|
1461
1504
|
try {
|
|
@@ -1480,13 +1523,9 @@ export const run = async (args: string[]): Promise<number> => {
|
|
|
1480
1523
|
console.error(errorText(error))
|
|
1481
1524
|
return 1
|
|
1482
1525
|
}
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
return 1
|
|
1489
|
-
} finally {
|
|
1490
|
-
releaseLock(watch.files.lock)
|
|
1491
|
-
}
|
|
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
|
|
1492
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,6 +48,10 @@ 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
56
|
export const lanePlanPath = (
|
|
52
57
|
root: string,
|
|
@@ -54,7 +59,7 @@ export const lanePlanPath = (
|
|
|
54
59
|
boardFile: string,
|
|
55
60
|
lane: SprintLane,
|
|
56
61
|
): string | null => {
|
|
57
|
-
const href = lane
|
|
62
|
+
const href = newestPlanHref(lane)
|
|
58
63
|
return href ? resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href) : null
|
|
59
64
|
}
|
|
60
65
|
|
|
@@ -102,6 +107,50 @@ export const watchedLaneParallel = (
|
|
|
102
107
|
return groups === null ? [] : parallelBatchOf(groups)
|
|
103
108
|
}
|
|
104
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
|
+
|
|
105
154
|
/**
|
|
106
155
|
* The rows the checkout's board file (`checkoutText`) has beyond the board at the base ref, counted as the herd
|
|
107
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,7 @@ 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'
|
|
16
29
|
import { PLAN_MISSING_EVENT, type PlanMissingEvent, planMissingLine } from './plan-missing'
|
|
17
30
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
18
31
|
import { SOURCE_FIELD } from './source-fields'
|
|
@@ -370,6 +383,48 @@ export const blockedDutyLines = (stopped: readonly string[], current: LaneSnapsh
|
|
|
370
383
|
.filter((lane) => current[lane]?.status === AGENT_STATUS.blocked)
|
|
371
384
|
.map((lane) => `${terminalSafe(lane)} ${AGENT_STATUS.blocked}: ${texts.blockedDuty}`)
|
|
372
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
|
+
|
|
373
428
|
const takeOverNote = ({ replaced, stillRunning }: TakeOver): string => {
|
|
374
429
|
if (stillRunning !== null) return ` (watcher ${stillRunning} did not stop)`
|
|
375
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(
|
|
@@ -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
|
@@ -32,6 +32,8 @@ export type WatchTexts = {
|
|
|
32
32
|
planMissingDuty: string
|
|
33
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. */
|
|
34
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
|
|
35
37
|
/** The standing duty while the main checkout is behind its base branch on the remote (`origin/main`), with the count of commits. */
|
|
36
38
|
mainCheckoutBehind: (remoteBase: string, count: number) => string
|
|
37
39
|
/** The standing duty while the main checkout has another branch than the base checked out (null for a detached head). */
|
|
@@ -76,6 +78,8 @@ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
|
|
|
76
78
|
"the herd shows the base ref's plan, not this one: run § 2 step 0 for it, copying from the lane's worktree",
|
|
77
79
|
laneModelDuty:
|
|
78
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`,
|
|
79
83
|
mainCheckoutBehind: (remoteBase, count) =>
|
|
80
84
|
`main checkout behind ${remoteBase} by ${count} (git merge --ff-only ${remoteBase})`,
|
|
81
85
|
mainCheckoutOffBase: (branch, baseBranch) => `main checkout on ${branch ?? 'a detached head'}, not ${baseBranch}`,
|