jackherd 1.24.0 → 1.26.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +22 -22
  4. package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
  5. package/dist/config.d.ts +4 -0
  6. package/dist/config.js +4 -1
  7. package/dist/plan-format.d.ts +121 -3
  8. package/dist/plan-format.js +176 -35
  9. package/docs/plan-contract.md +52 -5
  10. package/herdr-plugin/herdr-plugin.toml +1 -1
  11. package/install.sh +1 -1
  12. package/kit/hooks/hooks/approval-gate/index.ts +2 -2
  13. package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
  14. package/kit/hooks/hooks/open-question/record.ts +81 -1
  15. package/kit/hooks/hooks/open-question/run.ts +17 -3
  16. package/kit/hooks/hooks/question-session-end/index.ts +18 -0
  17. package/kit/hooks/hooks/question-session-end/run.ts +37 -0
  18. package/kit/hooks/hooks/sent-file/record.ts +4 -1
  19. package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
  20. package/kit/hooks/hooks/shared/boards.ts +22 -6
  21. package/kit/hooks/hooks.json +8 -0
  22. package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
  23. package/kit/workflow/skills/orchestrate/references/waiting.md +15 -1
  24. package/kit/workflow/skills/plan/SKILL.md +3 -1
  25. package/kit/workflow/skills/plan/references/template-English.md +2 -0
  26. package/kit/workflow/skills/plan/references/template-German.md +2 -0
  27. package/package.json +1 -1
  28. package/schemas/jackherd.schema.json +7 -0
  29. package/src/brief/command.ts +1 -1
  30. package/src/brief/lane-facts.ts +2 -2
  31. package/src/check/command.ts +2 -1
  32. package/src/check/plan-structure-check.ts +68 -14
  33. package/src/check/settings.ts +6 -1
  34. package/src/check/skill-budget-defaults.ts +9 -4
  35. package/src/config/schema.ts +3 -0
  36. package/src/lane/close-board.ts +4 -4
  37. package/src/lane/close-command.ts +2 -2
  38. package/src/lane/dev-command.ts +1 -1
  39. package/src/lane/port-command.ts +1 -1
  40. package/src/plan-contract/command.ts +2 -1
  41. package/src/plan-contract/contract.ts +30 -9
  42. package/src/plan-format/bare-codes.ts +4 -4
  43. package/src/plan-format/dependencies.ts +5 -5
  44. package/src/plan-format/lanes.ts +3 -4
  45. package/src/plan-format/markdown.ts +125 -10
  46. package/src/plan-format/open-status.ts +15 -0
  47. package/src/plan-format/phases.ts +45 -4
  48. package/src/plan-format/plan-head.ts +31 -9
  49. package/src/plan-format/plan-structure.ts +43 -7
  50. package/src/plan-format/progress.ts +3 -0
  51. package/src/plan-format/sprint-document.ts +4 -4
  52. package/src/plan-format/unrecognized-sections.ts +21 -0
  53. package/src/plan-format/waiting.ts +13 -13
  54. package/src/plans/board-close.ts +25 -16
  55. package/src/plans/board-pick.ts +1 -1
  56. package/src/plans/closable-records.ts +11 -10
  57. package/src/plans/close-reason.ts +19 -0
  58. package/src/plans/ensure-panes.ts +2 -2
  59. package/src/plans/layout-summary.ts +8 -1
  60. package/src/plans/layout.ts +14 -5
  61. package/src/plans/live.ts +3 -2
  62. package/src/plans/question-records.ts +23 -2
  63. package/src/plans/questions-read.ts +2 -2
  64. package/src/plans/sent-files-read.ts +2 -2
  65. package/src/plans/sent-files.ts +12 -4
  66. package/src/plans/settings.ts +3 -0
  67. package/src/plans/snapshot.ts +9 -5
  68. package/src/plans/sprint-export.ts +1 -1
  69. package/src/plans/texts.ts +3 -0
  70. package/src/plans/view.ts +1 -0
  71. package/src/resume/coordinator-read.ts +2 -2
  72. package/src/settled/command.ts +1 -1
  73. package/src/settled/settle.ts +5 -5
  74. package/src/sync/render.ts +20 -5
  75. package/src/sync/sync-plan.ts +6 -2
  76. package/src/sync/sync.ts +3 -2
  77. package/src/vocabulary/index.ts +192 -4
  78. package/src/waiting/command.ts +82 -26
  79. package/src/watch/appointments.ts +13 -20
  80. package/src/watch/command.ts +100 -6
  81. package/src/watch/lane-progress.ts +1 -1
  82. package/src/watch/lane-report.ts +4 -0
  83. package/src/watch/merge-overlap-read.ts +2 -2
  84. package/src/watch/obsolete-proposals.ts +175 -0
  85. package/src/watch/obsolete-records.ts +77 -0
  86. package/src/watch/plan-missing-read.ts +3 -1
  87. package/src/watch/reference-paths.ts +2 -0
  88. package/src/watch/watch-events.ts +20 -0
@@ -224,5 +224,5 @@ export const boardCheckoutRows = (
224
224
  checkoutText: string,
225
225
  ): number => {
226
226
  const atBase = textAtBase(baseReaderOf(root, baseRefOf(settings)), boardRepoPathOf(root, settings, boardFile))
227
- return atBase === null ? 0 : boardRowsOf(atBase, checkoutText, settings.planLanguage).rowsFromCheckout
227
+ return atBase === null ? 0 : boardRowsOf(atBase, checkoutText, settings.boardLanguage).rowsFromCheckout
228
228
  }
@@ -27,6 +27,8 @@ import type { LaneParallelEvent } from './lane-parallel'
27
27
  import { laneStalledLine } from './lane-stalled'
28
28
  import { type LaneSnapshot, NO_AGENT } from './lane-watch'
29
29
  import { MERGE_OVERLAP, type MergeOverlap } from './merge-duties'
30
+ import { waitingObsoleteLine } from './obsolete-proposals'
31
+ import { waitingClosedLine } from './obsolete-records'
30
32
  import { PLAN_MISSING_EVENT, type PlanMissingEvent, planMissingLine } from './plan-missing'
31
33
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
32
34
  import { SOURCE_FIELD } from './source-fields'
@@ -204,6 +206,8 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
204
206
  if (laneLine !== null) return laneLine
205
207
  if (event.kind === WATCH_EVENT.taskParallel) return `=== ${terminalSafe(event.line)}`
206
208
  if (event.kind === WATCH_EVENT.unlinkedArtifact) return `=== ${unlinkedArtifactLine(event)}`
209
+ if (event.kind === WATCH_EVENT.waitingClosed) return `=== ${waitingClosedLine(event)}`
210
+ if (event.kind === WATCH_EVENT.waitingObsolete) return `=== ${waitingObsoleteLine(event)}`
207
211
  if (event.kind === WATCH_EVENT.approval)
208
212
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
209
213
  if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
@@ -1,7 +1,7 @@
1
1
  import { STACKS } from '../config/detect'
2
2
  import { isObjectId } from '../lane/close-survey'
3
3
  import { parseSprintLanes } from '../plan-format'
4
- import type { PlansSettings } from '../plans/settings'
4
+ import { type PlansSettings } from '../plans/settings'
5
5
  import { lanePlanPath, watchedLanePlanText } from './lane-progress'
6
6
  import type { OverlapOf } from './lane-report'
7
7
  import { type LaneScope, type MergeOverlap, overlapOf } from './merge-duties'
@@ -39,7 +39,7 @@ const scopeOf = (
39
39
  boardText: string,
40
40
  name: string,
41
41
  ): LaneScope | null => {
42
- const lane = parseSprintLanes(boardText, settings.planLanguage).find((candidate) => candidate.name === name)
42
+ const lane = parseSprintLanes(boardText, settings.boardLanguage).find((candidate) => candidate.name === name)
43
43
  if (lane === undefined) return null
44
44
  const text = watchedLanePlanText(root, settings, boardFile, lane)
45
45
  if (text === null) return null
@@ -0,0 +1,175 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { terminalSafe } from '../plan-format/terminal-text'
3
+ import type { WaitingItem } from '../plan-format/waiting'
4
+ import { CLOSE_REASON, closeReasonOf } from '../plans/close-reason'
5
+ import { type QuestionRecord, questionTextOf } from '../plans/question-records'
6
+ import { type SentFileRecord, sentLaneOf, sentTextOf } from '../plans/sent-files'
7
+ import { laneDirectoryOf } from '../plans/unlisted-lanes'
8
+ import { isRecord } from '../shared/is-record'
9
+ import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
10
+ import { latestPullRequestOf, PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
11
+
12
+ export const WAITING_OBSOLETE_EVENT = 'waiting obsolete?'
13
+
14
+ /** A point of "Your turn" that looks obsolete: a record's id or a board row's key, why, and what it shows. */
15
+ export type Proposal = { key: string; reason: string; text: string }
16
+
17
+ export type WaitingObsoleteEvent = { kind: typeof WAITING_OBSOLETE_EVENT } & Proposal
18
+
19
+ /** Per open point, the reasons already proposed for it; a point that closed or left the board has no entry. */
20
+ export type ProposalState = Record<string, string[]>
21
+
22
+ /** The pull request numbers and lane names a free text names. */
23
+ export type TextReferences = { pullRequests: number[]; lanes: string[] }
24
+
25
+ /** What a heartbeat read for its proposals; null where the source did not answer, so nothing is said about it. */
26
+ export type ProposalInput = {
27
+ sends: readonly SentFileRecord[]
28
+ questions: readonly QuestionRecord[]
29
+ /** The open rows of the board's waiting section; null when the board cannot be read. */
30
+ board: readonly WaitingItem[] | null
31
+ /** The pull requests of the watcher's last GitHub read; null before one answered. */
32
+ pullRequests: readonly WatchedPullRequest[] | null
33
+ /** Lane → branch, as the watcher keeps it after a lane was closed. */
34
+ laneBranches: Readonly<Record<string, string>>
35
+ worktreeDirectory: string
36
+ /** The lanes whose directory was missing in two heartbeats in a row (`laneAbsenceOf`). */
37
+ gone: ReadonlySet<string>
38
+ /** The lanes known to have existed; a board row naming a lane that never had a worktree proposes nothing. */
39
+ knownLanes: ReadonlySet<string>
40
+ }
41
+
42
+ /** The word that names a lane before its name in backticks, per board language (`Spur \`name\``); every one is read. */
43
+ const LANE_WORD: Record<PlanLanguage, string> = { [PLAN_LANGUAGE.de]: 'Spur', [PLAN_LANGUAGE.en]: 'lane' }
44
+
45
+ /** `#<digits>` standing alone: not `#AB12CD`, not `#12a`, not a part of a word or an address (`a#1`, `/#1`). */
46
+ const PULL_REQUEST_REFERENCE = /(?<![\w#&/])#(\d+)(?!\w)/g
47
+
48
+ const LANE_REFERENCE = new RegExp(`\\b(?:${Object.values(LANE_WORD).join('|')})\\s+\`([^\`\\s]+)\``, 'gi')
49
+
50
+ const ROW_KEY_PREFIX = 'row-'
51
+ const ROW_HASH_LENGTH = 8
52
+ const SUPERSEDED = 'superseded'
53
+ /** The reason of a question whose own session has ended (`SessionEnd`, also after `/clear`) with the question still open. */
54
+ const SESSION_ENDED = 'session ended'
55
+
56
+ /** What of a board row names its pull request or lane: the item and the origin. */
57
+ const rowText = ({ item, origin }: WaitingItem): string => `${item} ${origin ?? ''}`
58
+
59
+ /** The pull request numbers (`#<n>`) and lane names (`Spur \`name\``, `lane \`name\``) a free text names, each once. */
60
+ export const textReferencesOf = (text: string): TextReferences => ({
61
+ pullRequests: [...new Set([...text.matchAll(PULL_REQUEST_REFERENCE)].map((match) => Number(match[1])))],
62
+ lanes: [...new Set([...text.matchAll(LANE_REFERENCE)].map((match) => match[1]))],
63
+ })
64
+
65
+ /** The lanes the open points name: a send's lane, a question's lane by its directory, a board row's lanes in its text. */
66
+ export const waitingLanesOf = (
67
+ input: Pick<ProposalInput, 'sends' | 'questions' | 'board' | 'worktreeDirectory'>,
68
+ ): string[] => [
69
+ ...input.sends.flatMap((record) =>
70
+ record.closedAt === null ? (sentLaneOf(record, input.worktreeDirectory) ?? []) : [],
71
+ ),
72
+ ...input.questions.flatMap((record) =>
73
+ record.closedAt === null ? (laneDirectoryOf(record.cwd, input.worktreeDirectory) ?? []) : [],
74
+ ),
75
+ ...(input.board ?? []).flatMap((row) => textReferencesOf(rowText(row)).lanes),
76
+ ]
77
+
78
+ /** A board row's key: `row-` and a short hash of its item, stable while the row's text stays. */
79
+ export const rowKeyOf = (item: string): string =>
80
+ `${ROW_KEY_PREFIX}${createHash('sha256').update(item).digest('hex').slice(0, ROW_HASH_LENGTH)}`
81
+
82
+ const hasEnded = ({ state }: WatchedPullRequest): boolean => state !== PULL_REQUEST_STATE.open
83
+
84
+ const pullRequestReason = ({ number, state }: WatchedPullRequest): string => `pr #${number} ${state.toLowerCase()}`
85
+
86
+ const laneRemovedReason = (lane: string): string => closeReasonOf(CLOSE_REASON.laneRemoved, lane)
87
+
88
+ const sendProposals = (input: ProposalInput): Proposal[] =>
89
+ input.sends.flatMap((record) => {
90
+ const lane = sentLaneOf(record, input.worktreeDirectory)
91
+ const branch = lane === null ? undefined : input.laneBranches[lane]
92
+ const pullRequest = branch === undefined ? null : latestPullRequestOf(input.pullRequests ?? [], branch)
93
+ return record.closedAt === null && pullRequest !== null && hasEnded(pullRequest)
94
+ ? [{ key: record.id, reason: pullRequestReason(pullRequest), text: sentTextOf(record) }]
95
+ : []
96
+ })
97
+
98
+ const questionReasons = (record: QuestionRecord, input: ProposalInput): string[] => {
99
+ const lane = laneDirectoryOf(record.cwd, input.worktreeDirectory)
100
+ const superseded = record.supersededBy ? [`${SUPERSEDED}: ${record.supersededBy.question}`] : []
101
+ const ended = typeof record.sessionEndedAt === 'number' ? [SESSION_ENDED] : []
102
+ return [...superseded, ...ended, ...(lane !== null && input.gone.has(lane) ? [laneRemovedReason(lane)] : [])]
103
+ }
104
+
105
+ const questionProposals = (input: ProposalInput): Proposal[] =>
106
+ input.questions
107
+ .filter(({ closedAt }) => closedAt === null)
108
+ .flatMap((record) =>
109
+ questionReasons(record, input).map((reason) => ({ key: record.id, reason, text: questionTextOf(record) })),
110
+ )
111
+
112
+ const rowReasons = (row: WaitingItem, input: ProposalInput): string[] => {
113
+ const { pullRequests, lanes } = textReferencesOf(rowText(row))
114
+ const ended = (input.pullRequests ?? []).filter((pullRequest) => pullRequests.includes(pullRequest.number))
115
+ const removed = lanes.filter((lane) => input.gone.has(lane) && input.knownLanes.has(lane))
116
+ return [...ended.filter(hasEnded).map(pullRequestReason), ...removed.map(laneRemovedReason)]
117
+ }
118
+
119
+ const boardProposals = (input: ProposalInput): Proposal[] =>
120
+ (input.board ?? []).flatMap((row) =>
121
+ rowReasons(row, input).map((reason) => ({ key: rowKeyOf(row.item), reason, text: row.item })),
122
+ )
123
+
124
+ /**
125
+ * What looks obsolete but is not safe to close: an open send whose lane's pull request was merged or closed, an open
126
+ * board row naming such a pull request (`#<n>`) or a lane whose directory is gone, an open question a later dialog
127
+ * probably answered (`supersededBy`), whose session has ended (`session ended`) or whose lane is gone. The coordinator decides; nothing here closes anything.
128
+ */
129
+ export const obsoleteProposalsOf = (input: ProposalInput): Proposal[] => [
130
+ ...sendProposals(input),
131
+ ...questionProposals(input),
132
+ ...boardProposals(input),
133
+ ]
134
+
135
+ /** The keys still open: the open records and board rows; while the board cannot be read, the rows proposed before. */
136
+ const openKeysOf = (previous: ProposalState, input: ProposalInput): Set<string> => {
137
+ const records = [...input.sends, ...input.questions].filter(({ closedAt }) => closedAt === null).map(({ id }) => id)
138
+ const rows =
139
+ input.board === null
140
+ ? Object.keys(previous).filter((key) => key.startsWith(ROW_KEY_PREFIX))
141
+ : input.board.map(({ item }) => rowKeyOf(item))
142
+ return new Set([...records, ...rows])
143
+ }
144
+
145
+ /**
146
+ * One event per point and reason, once while the point is open: a reason proposed before is not said again, and a
147
+ * point that closed leaves the state. A source that did not answer keeps what was proposed from it.
148
+ */
149
+ export const obsoleteProposalEvents = (
150
+ previous: ProposalState,
151
+ input: ProposalInput,
152
+ ): { events: WaitingObsoleteEvent[]; state: ProposalState } => {
153
+ const open = openKeysOf(previous, input)
154
+ const state: ProposalState = Object.fromEntries(Object.entries(previous).filter(([key]) => open.has(key)))
155
+ const events: WaitingObsoleteEvent[] = []
156
+ for (const proposal of obsoleteProposalsOf(input)) {
157
+ const said = state[proposal.key] ?? []
158
+ if (said.includes(proposal.reason)) continue
159
+ events.push({ kind: WAITING_OBSOLETE_EVENT, ...proposal })
160
+ state[proposal.key] = [...said, proposal.reason]
161
+ }
162
+ return { events, state }
163
+ }
164
+
165
+ const quoted = (text: string): string => terminalSafe(text).replaceAll('"', "'")
166
+
167
+ /** `waiting obsolete? <key> <reason> "<text>"`; double quotes inside become single ones, so the quotes stay the text's bounds. */
168
+ export const waitingObsoleteLine = ({ key, reason, text }: WaitingObsoleteEvent): string =>
169
+ `${WAITING_OBSOLETE_EVENT} ${terminalSafe(key)} ${quoted(reason)} "${quoted(text)}"`
170
+
171
+ export const isProposalState = (value: unknown): value is ProposalState =>
172
+ isRecord(value) &&
173
+ Object.values(value).every(
174
+ (reasons) => Array.isArray(reasons) && reasons.every((reason) => typeof reason === 'string'),
175
+ )
@@ -0,0 +1,77 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { CLOSE_REASON, closeReasonOf } from '../plans/close-reason'
3
+ import { latestPerFile, type SentFileRecord, sentLaneOf } from '../plans/sent-files'
4
+
5
+ export const WAITING_CLOSED_EVENT = 'waiting closed'
6
+
7
+ /** A record the watcher closes on a safe signal, and the reason it writes to it (`closeReasonOf`). */
8
+ export type Closing = { id: string; reason: string }
9
+
10
+ /** What a heartbeat knows of the lanes' directories: the ones missing now, and those missing in two heartbeats in a row. */
11
+ export type LaneAbsence = { missing: string[]; gone: Set<string> }
12
+
13
+ export type WaitingClosedEvent = { kind: typeof WAITING_CLOSED_EVENT; closings: Closing[] }
14
+
15
+ /**
16
+ * Which of the `lanes` have no directory among `existing`, and which of those were missing at the last heartbeat
17
+ * too (`previousMissing`): only these count as gone, so a worktree renamed or slow to list closes nothing.
18
+ */
19
+ export const laneAbsenceOf = (
20
+ lanes: Iterable<string>,
21
+ existing: ReadonlySet<string>,
22
+ previousMissing: readonly string[],
23
+ ): LaneAbsence => {
24
+ const missing = [...new Set(lanes)].filter((lane) => !existing.has(lane)).sort()
25
+ return { missing, gone: new Set(missing.filter((lane) => previousMissing.includes(lane))) }
26
+ }
27
+
28
+ /** Each older send of a first entry that a newer send has, closed with the newer send's id (`latestPerFile`). */
29
+ const olderVersionsOf = (records: readonly SentFileRecord[]): Closing[] => {
30
+ const ordered = [...records].sort((left, right) => left.sentAt - right.sentAt)
31
+ const latest = latestPerFile(ordered)
32
+ const newestOf = new Map(latest.map((record) => [record.files[0], record.id]))
33
+ const kept = new Set(latest.map(({ id }) => id))
34
+ return ordered.flatMap((record) => {
35
+ const newer = newestOf.get(record.files[0])
36
+ return kept.has(record.id) || newer === undefined
37
+ ? []
38
+ : [{ id: record.id, reason: closeReasonOf(CLOSE_REASON.newerVersion, newer) }]
39
+ })
40
+ }
41
+
42
+ /**
43
+ * The open sends a safe signal makes obsolete: an older send of a first entry that was sent again (`newer version:
44
+ * <id>`, whoever sent it), and a send whose lane is gone (`lane removed: <name>`); the newer version wins when both hold.
45
+ * A send without a lane (lane null) is never closed as removed, and a question record is never closed here: the
46
+ * coordinator decides on those.
47
+ */
48
+ export const obsoleteSendsOf = (
49
+ records: readonly SentFileRecord[],
50
+ worktreeDirectory: string,
51
+ gone: ReadonlySet<string>,
52
+ ): Closing[] => {
53
+ const open = records.flatMap((record) => {
54
+ const lane = sentLaneOf(record, worktreeDirectory)
55
+ return record.closedAt === null ? [{ id: record.id, lane }] : []
56
+ })
57
+ const openIds = new Set(open.map(({ id }) => id))
58
+ const newer = olderVersionsOf(records).filter(({ id }) => openIds.has(id))
59
+ const closedByNewer = new Set(newer.map(({ id }) => id))
60
+ const removed = open.flatMap(({ id, lane }) =>
61
+ lane !== null && gone.has(lane) && !closedByNewer.has(id)
62
+ ? [{ id, reason: closeReasonOf(CLOSE_REASON.laneRemoved, lane) }]
63
+ : [],
64
+ )
65
+ return [...newer, ...removed]
66
+ }
67
+
68
+ /** The closings grouped by reason, each group one call of the log's `close`. */
69
+ export const closingsByReason = (closings: readonly Closing[]): Map<string, string[]> => {
70
+ const groups = new Map<string, string[]>()
71
+ for (const { id, reason } of closings) groups.set(reason, [...(groups.get(reason) ?? []), id])
72
+ return groups
73
+ }
74
+
75
+ /** `waiting closed <n>: <id> <reason>; …`, the reasons without control characters. */
76
+ export const waitingClosedLine = ({ closings }: WaitingClosedEvent): string =>
77
+ `${WAITING_CLOSED_EVENT} ${closings.length}: ${closings.map(({ id, reason }) => `${terminalSafe(id)} ${terminalSafe(reason)}`).join('; ')}`
@@ -19,7 +19,9 @@ import {
19
19
  const worktreePlanOf = (root: string, settings: PlansSettings, boardFile: string, laneName: string): string | null => {
20
20
  const file = join(worktreeOf(root, settings, laneName), boardRepoPathOf(root, settings, boardFile))
21
21
  if (!existsSync(file)) return null
22
- const lane = parseSprintLanes(readFileSync(file, 'utf8'), settings.planLanguage).find(({ name }) => name === laneName)
22
+ const lane = parseSprintLanes(readFileSync(file, 'utf8'), settings.boardLanguage).find(
23
+ ({ name }) => name === laneName,
24
+ )
23
25
  const path = lane === undefined ? null : lanePlanPath(root, settings, boardFile, lane)
24
26
  return path === null || isOutsideRepository(path) ? null : path
25
27
  }
@@ -53,6 +53,8 @@ const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
53
53
  [WATCH_EVENT.noTaskSignal]: REFERENCE.events,
54
54
  [WATCH_EVENT.unlinkedArtifact]: REFERENCE.events,
55
55
  [WATCH_EVENT.planMissing]: REFERENCE.events,
56
+ [WATCH_EVENT.waitingClosed]: REFERENCE.waiting,
57
+ [WATCH_EVENT.waitingObsolete]: REFERENCE.waiting,
56
58
  [WATCH_EVENT.deploy]: REFERENCE.events,
57
59
  [WATCH_EVENT.laneStalled]: REFERENCE.events,
58
60
  [WATCH_EVENT.due]: REFERENCE.appointments,
@@ -23,6 +23,13 @@ import { isLaneModels, LANE_MODEL_EVENT, type LaneModelEvent, type LaneModels }
23
23
  import { isLaneParallels, LANE_PARALLEL_EVENT, type LaneParallelEvent, type LaneParallels } from './lane-parallel'
24
24
  import { isStallState, LANE_STALLED_EVENT, type LaneStalledEvent, type StallState } from './lane-stalled'
25
25
  import { type LaneSnapshot, type SeenSeqs, seenSeqsOf } from './lane-watch'
26
+ import {
27
+ isProposalState,
28
+ type ProposalState,
29
+ WAITING_OBSOLETE_EVENT,
30
+ type WaitingObsoleteEvent,
31
+ } from './obsolete-proposals'
32
+ import { WAITING_CLOSED_EVENT, type WaitingClosedEvent } from './obsolete-records'
26
33
  import { isPlanMissingState, PLAN_MISSING_EVENT, type PlanMissingEvent, type PlanMissingState } from './plan-missing'
27
34
  import { emptyPreviewState, isPreviewState, PREVIEW_EVENT, type PreviewEvent, type PreviewState } from './previews'
28
35
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
@@ -71,6 +78,8 @@ export const WATCH_EVENT = {
71
78
  laneStalled: LANE_STALLED_EVENT,
72
79
  unlinkedArtifact: UNLINKED_ARTIFACT_EVENT,
73
80
  planMissing: PLAN_MISSING_EVENT,
81
+ waitingClosed: WAITING_CLOSED_EVENT,
82
+ waitingObsolete: WAITING_OBSOLETE_EVENT,
74
83
  approval: APPROVAL_EVENT,
75
84
  source: SOURCE_EVENT,
76
85
  due: APPOINTMENT_EVENT.due,
@@ -137,6 +146,10 @@ export type WatchState = {
137
146
  unlinkedArtifacts: UnlinkedState
138
147
  /** Per working lane whose board row links a plan the base ref lacks: the plan last reported as `plan missing on base`. */
139
148
  planMissing: PlanMissingState
149
+ /** The lanes of open waiting points whose directory was missing at the last heartbeat; missing twice in a row, a lane is gone. */
150
+ waitingMissingLanes: string[]
151
+ /** Per open waiting point (record id or board row key): the reasons already proposed as `waiting obsolete?`. */
152
+ waitingProposals: ProposalState
140
153
  /** The ids of the approval requests on disk when they were last read, each reported once while it is open. */
141
154
  approvals: string[]
142
155
  /** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
@@ -207,6 +220,8 @@ export type WatchEvent =
207
220
  | LaneStalledEvent
208
221
  | UnlinkedArtifactEvent
209
222
  | PlanMissingEvent
223
+ | WaitingClosedEvent
224
+ | WaitingObsoleteEvent
210
225
  | ApprovalEvent
211
226
  | SourceEvent
212
227
  | AppointmentEvent
@@ -248,6 +263,8 @@ export const emptyWatchState = (): WatchState => ({
248
263
  laneStalls: {},
249
264
  unlinkedArtifacts: {},
250
265
  planMissing: {},
266
+ waitingMissingLanes: [],
267
+ waitingProposals: {},
251
268
  approvals: [],
252
269
  sources: emptySourceState(),
253
270
  watchedSources: null,
@@ -304,6 +321,9 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
304
321
  isStallState(value.laneStalls) &&
305
322
  isUnlinkedState(value.unlinkedArtifacts) &&
306
323
  isPlanMissingState(value.planMissing) &&
324
+ Array.isArray(value.waitingMissingLanes) &&
325
+ value.waitingMissingLanes.every((lane) => typeof lane === 'string') &&
326
+ isProposalState(value.waitingProposals) &&
307
327
  Array.isArray(value.approvals) &&
308
328
  value.approvals.every((id) => typeof id === 'string') &&
309
329
  isSourceState(value.sources) &&