jackherd 1.25.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.
@@ -16,6 +16,8 @@ export type SentFileRecord = {
16
16
  session: string
17
17
  agentId: string | null
18
18
  closedAt: number | null
19
+ /** Who closed the record and why (`closeReasonOf`); absent or null on a record that predates the field or is open. */
20
+ closedReason?: string | null
19
21
  }
20
22
 
21
23
  /** A badge for a sent file: a `file:` address and the file's name. */
@@ -55,7 +57,8 @@ export const isSentFileRecord = (value: unknown): value is SentFileRecord => {
55
57
  typeof record.cwd === 'string' &&
56
58
  typeof record.session === 'string' &&
57
59
  (record.agentId === null || typeof record.agentId === 'string') &&
58
- (record.closedAt === null || typeof record.closedAt === 'number')
60
+ (record.closedAt === null || typeof record.closedAt === 'number') &&
61
+ (record.closedReason == null || typeof record.closedReason === 'string')
59
62
  )
60
63
  }
61
64
 
@@ -69,7 +72,7 @@ export const sentLaneOf = ({ files, cwd }: SentFileRecord, worktreeDirectory: st
69
72
  .find((lane) => lane !== null) ?? laneDirectoryOf(cwd, worktreeDirectory)
70
73
 
71
74
  /** The records without an earlier send of the same first file: an artifact updated each round is one send, the newest. */
72
- const latestPerFile = (records: readonly SentFileRecord[]): SentFileRecord[] =>
75
+ export const latestPerFile = (records: readonly SentFileRecord[]): SentFileRecord[] =>
73
76
  records.filter((record, index) => !records.slice(index + 1).some((later) => later.files[0] === record.files[0]))
74
77
 
75
78
  /**
@@ -94,12 +97,17 @@ export const sentChipsByLane = (
94
97
  return lanes
95
98
  }
96
99
 
100
+ /** What a row of "Your turn" shows of a send: the first file's label and how many more follow. */
101
+ export const sentTextOf = (record: SentFileRecord): string => {
102
+ const more = record.files.length > 1 ? ` +${record.files.length - 1}` : ''
103
+ return `${labelOf(record.files[0], record.caption)}${more}`
104
+ }
105
+
97
106
  const itemOf = (record: SentFileRecord): WaitingItem => {
98
107
  const [first] = record.files
99
- const more = record.files.length > 1 ? ` +${record.files.length - 1}` : ''
100
108
  return {
101
109
  since: clockText(record.sentAt),
102
- item: `${labelOf(first, record.caption)}${more}`,
110
+ item: sentTextOf(record),
103
111
  kind: WAITING_KIND.file,
104
112
  id: record.id,
105
113
  open: true,
@@ -1,14 +1,64 @@
1
1
  import { installedReferenceHint } from '../lane/skill-hint'
2
+ import { clockText, DAY_MS } from '../plan-format/clock'
2
3
  import { terminalSafe } from '../plan-format/terminal-text'
3
4
  import type { WaitingItem } from '../plan-format/waiting'
5
+ import { CLOSE_REASON, closeReasonOf } from '../plans/close-reason'
4
6
  import { mainCheckout } from '../plans/git'
7
+ import { type QuestionRecord, questionTextOf } from '../plans/question-records'
5
8
  import { closeAllQuestionRecords, closeQuestionRecords } from '../plans/questions-read'
9
+ import { type SentFileRecord, sentTextOf } from '../plans/sent-files'
6
10
  import { closeAllSentRecords, closeSentRecords } from '../plans/sent-files-read'
7
- import { recordWaitingItems } from '../plans/waiting-records'
11
+ import { readWaitingRecords, recordWaitingItems } from '../plans/waiting-records'
8
12
  import { CLI_NAME } from '../version/release'
9
13
  import { REFERENCE, referenceLine } from '../watch/reference-paths'
10
14
 
11
- const USAGE = `usage: ${CLI_NAME} waiting [close <id> ... | close --all]`
15
+ const USAGE = `usage: ${CLI_NAME} waiting [--closed | close <id> ... [--reason "<text>"] | close --all [--reason "<text>"]]`
16
+
17
+ const CLOSE_ALL = '--all'
18
+ const CLOSED = '--closed'
19
+ const REASON = '--reason'
20
+
21
+ /** A closed record as `jh waiting --closed` lists it. */
22
+ type ClosedRecord = { id: string; closedAt: number; closedReason?: string | null; text: string }
23
+
24
+ const closedRecordsOf = (sent: readonly SentFileRecord[], questions: readonly QuestionRecord[]): ClosedRecord[] =>
25
+ [
26
+ ...sent.map((record) => ({ ...record, text: sentTextOf(record) })),
27
+ ...questions.map((record) => ({ ...record, text: questionTextOf(record) })),
28
+ ].flatMap(({ id, closedAt, closedReason, text }) => (closedAt === null ? [] : [{ id, closedAt, closedReason, text }]))
29
+
30
+ /**
31
+ * The lines `jh waiting --closed` prints: the records closed within the last 24 hours, newest first, each as
32
+ * `id · time of closing · reason · text` (a dash for a record closed without a reason: one that predates the field, or one the hook closed itself); a note when none.
33
+ */
34
+ export const closedListing = (
35
+ sent: readonly SentFileRecord[],
36
+ questions: readonly QuestionRecord[],
37
+ now: number,
38
+ ): string[] => {
39
+ const recent = closedRecordsOf(sent, questions)
40
+ .filter(({ closedAt }) => now - closedAt <= DAY_MS)
41
+ .sort((left, right) => right.closedAt - left.closedAt)
42
+ return recent.length === 0
43
+ ? ['nothing was closed in the last 24 hours']
44
+ : recent.map(({ id, closedAt, closedReason, text }) =>
45
+ terminalSafe(`${id} · ${clockText(closedAt)} · ${closedReason ?? '-'} · ${text}`),
46
+ )
47
+ }
48
+
49
+ /** What `jh waiting close` was asked to close and with which reason. */
50
+ export type CloseArguments = { all: true; reason: string } | { all: false; ids: string[]; reason: string }
51
+
52
+ /** The arguments after `close`: the ids or `--all`, then optionally `--reason "<text>"`; the reason is `coordinator` plus the text. Null when they do not fit. */
53
+ export const closeArgumentsOf = (args: readonly string[]): CloseArguments | null => {
54
+ const at = args.indexOf(REASON)
55
+ if (at !== -1 && at !== args.length - 2) return null
56
+ const ids = at === -1 ? [...args] : args.slice(0, at)
57
+ const all = ids.includes(CLOSE_ALL)
58
+ if (ids.length === 0 || (all && ids.length > 1)) return null
59
+ const reason = closeReasonOf(CLOSE_REASON.coordinator, at === -1 ? '' : args[at + 1])
60
+ return all ? { all, reason } : { all, ids, reason }
61
+ }
12
62
 
13
63
  /**
14
64
  * The lines `jh waiting` prints: id, time and text of each item, made safe for the terminal, then the reference that
@@ -19,41 +69,47 @@ export const waitingListing = (items: readonly WaitingItem[], hint = ''): string
19
69
  ? ['nothing waits on you']
20
70
  : [...items.map(({ since, item, id }) => terminalSafe(`${id} · ${since} · ${item}`)), ...referenceLine(hint)]
21
71
 
22
- const CLOSE_ALL = '--all'
23
-
24
- /** Closes the records the ids name, files and questions alike; how many it closed. */
25
- export const closeWaiting = (root: string, ids: readonly string[], now: number): number =>
26
- closeSentRecords(root, ids, now) + closeQuestionRecords(root, ids, now)
72
+ /** Closes the records the ids name with `reason`, files and questions alike; how many it closed. */
73
+ export const closeWaiting = (root: string, ids: readonly string[], now: number, reason: string): number =>
74
+ closeSentRecords(root, ids, now, reason) + closeQuestionRecords(root, ids, now, reason)
27
75
 
28
- /** Closes every open record, files and questions alike; how many it closed. */
29
- export const closeAllWaiting = (root: string, now: number): number =>
30
- closeAllSentRecords(root, now) + closeAllQuestionRecords(root, now)
76
+ /** Closes every open record with `reason`, files and questions alike; how many it closed. */
77
+ export const closeAllWaiting = (root: string, now: number, reason: string): number =>
78
+ closeAllSentRecords(root, now, reason) + closeAllQuestionRecords(root, now, reason)
31
79
 
32
80
  /**
33
81
  * `jh waiting` lists the files sent to the user and the questions left unanswered that no one closed (their id, time
34
- * and text); `jh waiting close <id> ...` or `close --all` closes them once the user has answered. Exit 0, 1 on a wrong
35
- * command line.
82
+ * and text); `--closed` lists what was closed in the last 24 hours with its reason; `jh waiting close <id> ...` or
83
+ * `close --all` closes them once the user has answered. Exit 0, 1 on a wrong command line.
36
84
  */
37
85
  export const run = async (args: string[]): Promise<number> => {
38
86
  const root = mainCheckout(process.cwd())
39
- if (args.length === 0) {
40
- for (const line of waitingListing(
41
- recordWaitingItems(root, Date.now()),
42
- installedReferenceHint(root, REFERENCE.waiting),
43
- ))
44
- console.log(line)
45
- return 0
46
- }
87
+ if (args.length === 0) return printWaiting(root)
88
+ if (args.length === 1 && args[0] === CLOSED) return printClosed(root)
47
89
  const [command, ...rest] = args
48
- if (command !== 'close' || rest.length === 0) {
90
+ const close = command === 'close' ? closeArgumentsOf(rest) : null
91
+ if (close === null) {
49
92
  console.error(USAGE)
50
93
  return 1
51
94
  }
52
- if (rest.includes(CLOSE_ALL) && rest.length > 1) {
53
- console.error(`${CLOSE_ALL} closes everything and takes no ids\n${USAGE}`)
54
- return 1
55
- }
56
- const closed = rest[0] === CLOSE_ALL ? closeAllWaiting(root, Date.now()) : closeWaiting(root, rest, Date.now())
95
+ const closed = close.all
96
+ ? closeAllWaiting(root, Date.now(), close.reason)
97
+ : closeWaiting(root, close.ids, Date.now(), close.reason)
57
98
  console.log(`closed ${closed}`)
58
99
  return 0
59
100
  }
101
+
102
+ const printWaiting = (root: string): number => {
103
+ for (const line of waitingListing(
104
+ recordWaitingItems(root, Date.now()),
105
+ installedReferenceHint(root, REFERENCE.waiting),
106
+ ))
107
+ console.log(line)
108
+ return 0
109
+ }
110
+
111
+ const printClosed = (root: string): number => {
112
+ const { sent, questions } = readWaitingRecords(root)
113
+ for (const line of closedListing(sent, questions, Date.now())) console.log(line)
114
+ return 0
115
+ }
@@ -50,12 +50,15 @@ import { contextReport } from '../context/report'
50
50
  import { agentLog } from '../context/session-log'
51
51
  import { type LaneProgress, parseSprintLanes, type SprintLane } from '../plan-format'
52
52
  import { terminalSafe } from '../plan-format/terminal-text'
53
+ import { openWaitingItems } from '../plan-format/waiting'
53
54
  import { boardFor, foundLanesOf } from '../plans/board-pick'
54
55
  import { ensurePlanPanes } from '../plans/ensure-panes'
55
56
  import { errorText } from '../plans/error-text'
56
57
  import { baseReaderOf, mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
57
58
  import { discoverLanes, isUnchanged, laneSetChange } from '../plans/lane-discovery'
58
59
  import { readerOncePerRef } from '../plans/plan-heads'
60
+ import { readQuestionRecords } from '../plans/questions-read'
61
+ import { closeSentRecords, readSentRecords } from '../plans/sent-files-read'
59
62
  import { CLOCK_LOCALE, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
60
63
  import { readRunningSubagents, subagentSourceOf } from '../plans/subagents-read'
61
64
  import { boardTexts } from '../plans/texts'
@@ -182,6 +185,21 @@ import {
182
185
  import { isBehindOnBase, mainCheckoutDutyLine } from './main-checkout'
183
186
  import { mergeOverlapReader } from './merge-overlap-read'
184
187
  import { monitorToolAvailable } from './monitor-tool'
188
+ import {
189
+ obsoleteProposalEvents,
190
+ type ProposalInput,
191
+ type ProposalState,
192
+ type WaitingObsoleteEvent,
193
+ waitingLanesOf,
194
+ } from './obsolete-proposals'
195
+ import {
196
+ type Closing,
197
+ closingsByReason,
198
+ laneAbsenceOf,
199
+ obsoleteSendsOf,
200
+ WAITING_CLOSED_EVENT,
201
+ type WaitingClosedEvent,
202
+ } from './obsolete-records'
185
203
  import { parseWatchOptions, type WatchOptions } from './options'
186
204
  import type { PlanMissingEvent, PlanMissingState } from './plan-missing'
187
205
  import { readPlanMissingEvents } from './plan-missing-read'
@@ -306,6 +324,8 @@ type Watch = {
306
324
  herdr: boolean
307
325
  /** `lanes.backend` and the worktree directory of the main checkout's config; undefined without one, then the backend is never touched. */
308
326
  backend: MainCheckoutBackend | undefined
327
+ /** Runs in the main checkout, not in a lane's worktree: only then does it close records of "Your turn" or propose to. */
328
+ inMainCheckout: boolean
309
329
  }
310
330
 
311
331
  /** The coordinator's agent name (null when none is found), its last read (null before the first one) and the tokens of that read (null without a value). */
@@ -376,6 +396,10 @@ type Watching = {
376
396
  unlinkedArtifacts: UnlinkedState
377
397
  /** Per working lane, the plan last reported as `plan missing on base`; persisted like the fingerprints. */
378
398
  planMissing: PlanMissingState
399
+ /** The lanes of open waiting points missing at the last heartbeat; persisted like the fingerprints. */
400
+ waitingMissingLanes: string[]
401
+ /** The reasons proposed per open waiting point as `waiting obsolete?`; persisted like the fingerprints. */
402
+ waitingProposals: ProposalState
379
403
  /** The approval requests of the last poll; null before the first read, so the stored ids stay. */
380
404
  approvalRequests: ApprovalRequestFile[] | null
381
405
  /** 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. */
@@ -443,6 +467,8 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
443
467
  laneStalls: watching.laneStalls,
444
468
  unlinkedArtifacts: watching.unlinkedArtifacts,
445
469
  planMissing: watching.planMissing,
470
+ waitingMissingLanes: watching.waitingMissingLanes,
471
+ waitingProposals: watching.waitingProposals,
446
472
  approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
447
473
  ...recordedSourceState(withReads.sources, watching.sourceRuns, watching.sources.sources, watching.githubReadAt),
448
474
  appointments: watching.appointments
@@ -1322,6 +1348,69 @@ const unlinkedArtifactEventsOf = (watch: Watch, watching: Watching): UnlinkedArt
1322
1348
  }
1323
1349
  }
1324
1350
 
1351
+ /** Closes the sends through the log, one call per reason; returns the closings that were written, so a failed write is neither reported nor counted. */
1352
+ const closeObsoleteSends = (root: string, closings: readonly Closing[], now: number): Closing[] =>
1353
+ [...closingsByReason(closings)].flatMap(([reason, ids]) => {
1354
+ try {
1355
+ closeSentRecords(root, ids, now, reason)
1356
+ return ids.map((id) => ({ id, reason }))
1357
+ } catch {
1358
+ return []
1359
+ }
1360
+ })
1361
+
1362
+ /** What the proposals read besides the records: the pull requests of the last GitHub read and the lanes' branches. */
1363
+ const proposalContextOf = (watching: Watching): Pick<ProposalInput, 'pullRequests' | 'laneBranches' | 'knownLanes'> => {
1364
+ const laneBranches = { ...watching.state.laneBranches, ...watching.laneBranches }
1365
+ return {
1366
+ pullRequests: answered(watching.github)?.pullRequests ?? null,
1367
+ laneBranches,
1368
+ knownLanes: new Set([...Object.keys(laneBranches), ...watching.lanes]),
1369
+ }
1370
+ }
1371
+
1372
+ /**
1373
+ * This heartbeat's run over "Your turn", only from the main checkout: closes the sends a safe signal makes obsolete
1374
+ * (`waiting closed`, one event per run that closes something), then proposes what only looks obsolete (`waiting
1375
+ * obsolete?`, each point and reason once); records the missing lanes and the proposals in `watching`. Never writes
1376
+ * into the board. A read error closes nothing and reports nothing: the next heartbeat reads again.
1377
+ */
1378
+ const waitingObsoleteEventsOf = (
1379
+ watch: Watch,
1380
+ watching: Watching,
1381
+ now: number,
1382
+ ): (WaitingClosedEvent | WaitingObsoleteEvent)[] => {
1383
+ if (!watch.inMainCheckout) return []
1384
+ try {
1385
+ const worktreeDirectory = join(watch.root, watch.settings.worktreeDirectory)
1386
+ if (!existsSync(worktreeDirectory)) return []
1387
+ const sends = readSentRecords(watch.root)
1388
+ const questions = readQuestionRecords(watch.root)
1389
+ const boardText = readBoardText(watch)
1390
+ const board = boardText === null ? null : openWaitingItems(boardText, watch.settings.boardLanguage)
1391
+ const { missing, gone } = laneAbsenceOf(
1392
+ waitingLanesOf({ sends, questions, board, worktreeDirectory }),
1393
+ new Set(worktreeNames(worktreeDirectory)),
1394
+ watching.waitingMissingLanes,
1395
+ )
1396
+ const closings = closeObsoleteSends(watch.root, obsoleteSendsOf(sends, worktreeDirectory, gone), now)
1397
+ const closed = new Set(closings.map(({ id }) => id))
1398
+ const { events, state } = obsoleteProposalEvents(watching.waitingProposals, {
1399
+ sends: sends.filter(({ id }) => !closed.has(id)),
1400
+ questions,
1401
+ board,
1402
+ worktreeDirectory,
1403
+ gone,
1404
+ ...proposalContextOf(watching),
1405
+ })
1406
+ watching.waitingMissingLanes = missing
1407
+ watching.waitingProposals = state
1408
+ return closings.length === 0 ? events : [{ kind: WAITING_CLOSED_EVENT, closings }, ...events]
1409
+ } catch {
1410
+ return []
1411
+ }
1412
+ }
1413
+
1325
1414
  /**
1326
1415
  * This heartbeat's `plan missing on base` events: the working lanes whose board row, as their worktree has it, links a
1327
1416
  * plan the base ref lacks, each once; records the state in `watching`. Nothing when the boards or git cannot be read:
@@ -1351,13 +1440,14 @@ const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Reco
1351
1440
  return progress
1352
1441
  }
1353
1442
 
1354
- /** 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. */
1443
+ /** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts, the obsolete waiting points, the plans missing on the base. */
1355
1444
  const heartbeatEventsOf = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
1356
1445
  const progress = await workingProgressOf(watch, watching)
1357
1446
  return [
1358
1447
  ...taskParallelEventsOf(watch, watching, now),
1359
1448
  ...noTaskSignalEventsOf(watch, watching, progress, now),
1360
1449
  ...unlinkedArtifactEventsOf(watch, watching),
1450
+ ...waitingObsoleteEventsOf(watch, watching, now),
1361
1451
  ...planMissingEventsOf(watch, watching),
1362
1452
  ]
1363
1453
  }
@@ -1421,6 +1511,7 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
1421
1511
  ...taskParallelEventsOf(watch, watching, now),
1422
1512
  ...noTaskSignalEventsOf(watch, watching, progress, now),
1423
1513
  ...unlinkedArtifactEventsOf(watch, watching),
1514
+ ...waitingObsoleteEventsOf(watch, watching, now),
1424
1515
  ...planMissingEventsOf(watch, watching),
1425
1516
  ]
1426
1517
  for (const event of events) console.log(`${watchEventLine(event, null)}${hintFor(watch, eventReference(event))}`)
@@ -1589,6 +1680,8 @@ const initialWatching = (
1589
1680
  laneStalls: start.state.laneStalls,
1590
1681
  unlinkedArtifacts: start.state.unlinkedArtifacts,
1591
1682
  planMissing: start.state.planMissing,
1683
+ waitingMissingLanes: start.state.waitingMissingLanes,
1684
+ waitingProposals: start.state.waitingProposals,
1592
1685
  approvalRequests: null,
1593
1686
  appointments: null,
1594
1687
  reportQuestions: null,
@@ -1737,6 +1830,7 @@ const watchOf = (options: WatchOptions): Watch => {
1737
1830
  selfMergeMinutes: config.guard.selfMerge.approvalMinutes,
1738
1831
  herdr: insideHerdr(settings.adapter, process.env),
1739
1832
  backend: mainCheckoutBackendOf(root, checkoutRoot, config),
1833
+ inMainCheckout: checkoutRoot === root,
1740
1834
  }
1741
1835
  }
1742
1836
 
@@ -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)}`
@@ -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('; ')}`
@@ -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,