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.
Files changed (84) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +27 -19
  3. package/assets/icons/skills.svg +1 -0
  4. package/dist/config.d.ts +9 -1
  5. package/dist/config.js +24 -1
  6. package/dist/plan-format.d.ts +24 -1
  7. package/dist/plan-format.js +25 -5
  8. package/docs/plan-contract.md +84 -0
  9. package/docs/skills-dark.svg +15 -2
  10. package/docs/skills.svg +15 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/herdr-plugin/restore-plans.ts +56 -6
  13. package/install.sh +1 -1
  14. package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
  15. package/kit/hooks/hooks/merge-guard/decision.ts +3 -1
  16. package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
  17. package/kit/hooks/hooks/merge-guard/run.ts +6 -3
  18. package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
  19. package/kit/hooks/hooks/model-log/transcript.ts +4 -7
  20. package/kit/hooks/hooks/open-question/record.ts +4 -3
  21. package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
  22. package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
  23. package/kit/hooks/hooks/shared/transcript.ts +7 -4
  24. package/kit/hooks/hooks/task-hint/decision.ts +3 -2
  25. package/kit/hooks/hooks/task-hint/run.ts +2 -4
  26. package/kit/rules/rules/kit/pull-requests.md +1 -0
  27. package/kit/workflow/skills/orchestrate/SKILL.md +22 -9
  28. package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
  29. package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
  30. package/kit/workflow/skills/orchestrate/references/plan-commit.md +5 -5
  31. package/package.json +1 -1
  32. package/schemas/jackherd.schema.json +19 -0
  33. package/src/check/command.ts +1 -1
  34. package/src/check/file-kinds.ts +10 -8
  35. package/src/check/parallel-report.ts +10 -3
  36. package/src/check/settings.ts +4 -0
  37. package/src/check/skill-budget-defaults.ts +17 -5
  38. package/src/check/skill-budgets.ts +49 -19
  39. package/src/claude/paths.ts +12 -0
  40. package/src/cli.ts +1 -0
  41. package/src/config/schema.ts +27 -0
  42. package/src/context/fill.ts +2 -1
  43. package/src/context/log-vocabulary.ts +22 -0
  44. package/src/context/session-log.ts +2 -1
  45. package/src/lane-prompt/build.ts +10 -5
  46. package/src/lane-prompt/command.ts +6 -1
  47. package/src/lane-prompt/input.ts +4 -4
  48. package/src/lane-prompt/texts.ts +18 -10
  49. package/src/plan-contract/command.ts +69 -0
  50. package/src/plan-contract/contract.ts +144 -0
  51. package/src/plan-format/dependencies.ts +1 -4
  52. package/src/plan-format/index.ts +4 -0
  53. package/src/plan-format/lanes.ts +11 -0
  54. package/src/plan-format/markdown.ts +6 -5
  55. package/src/plan-format/model-table.ts +27 -0
  56. package/src/plans/activity-read.ts +98 -0
  57. package/src/plans/ask-coordinator.ts +20 -11
  58. package/src/plans/layout.ts +49 -4
  59. package/src/plans/live.ts +17 -4
  60. package/src/plans/plan-panes.ts +25 -5
  61. package/src/plans/snapshot.ts +18 -8
  62. package/src/plans/subagents-read.ts +4 -3
  63. package/src/plans/subagents.ts +2 -1
  64. package/src/plans/texts.ts +27 -1
  65. package/src/settled/send.ts +80 -4
  66. package/src/shared/parse-json.ts +8 -0
  67. package/src/sync/command.ts +17 -10
  68. package/src/sync/foreign-skills.ts +40 -0
  69. package/src/sync/global-kit.ts +31 -21
  70. package/src/sync/init.ts +2 -2
  71. package/src/sync/kit-exclude.ts +39 -0
  72. package/src/sync/plan-file.ts +1 -1
  73. package/src/sync/render.ts +2 -1
  74. package/src/sync/report.ts +37 -2
  75. package/src/sync/sync-result.ts +5 -2
  76. package/src/sync/sync.ts +13 -5
  77. package/src/task-signals/claude-signals.ts +6 -5
  78. package/src/watch/command.ts +56 -17
  79. package/src/watch/lane-message.ts +1 -1
  80. package/src/watch/lane-progress.ts +51 -2
  81. package/src/watch/lane-report.ts +57 -2
  82. package/src/watch/lane-watch.ts +2 -8
  83. package/src/watch/task-parallel-touched.ts +9 -2
  84. package/src/watch/texts.ts +4 -0
@@ -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 { boardCheckoutRows, watchedLaneParallel, watchedLaneProgress, watchedPlanPullRequests } from './lane-progress'
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<{ progress: Record<string, LaneProgress>; checkoutRows: number; notes: string[] }> => {
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 = async (
591
+ const statusLinesOf = (
581
592
  watch: Watch,
582
593
  watching: Watching,
583
594
  state: WatchState,
584
- ): Promise<{ laneLines: string[]; coordinator: string; others: string[]; rest: string[] }> => {
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 = await statusLinesOf(watch, watching, next)
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
- const watchLanes = async (watch: Watch) => {
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
- try {
1484
- await watchLanes(watch)
1485
- return 0
1486
- } catch (error) {
1487
- console.log(`watcher stopped: ${errorText(error)}`)
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 './lane-watch'
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.steps.filter((step) => step.planHref !== null).at(-1)?.planHref
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.
@@ -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 { currentPhase, isTickedUnmerged, type LaneProgress, phasesText } from '../plan-format'
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 { AGENT_STATUS, type LaneSnapshot, NO_AGENT } from './lane-watch'
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})`
@@ -1,7 +1,6 @@
1
- export type HerdrAgent = { name?: string; pane_id: string; agent_status: string; state_change_seq?: number }
1
+ import { AGENT_STATUS } from '../terminal/types'
2
2
 
3
- /** The herdr agent states; herdr's JSON delivers them as plain strings. */
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(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
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 !== 'tool_use' || !WRITING_TOOLS.has(String(block.name))) return []
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] : []
@@ -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}`,