jackherd 1.12.2 → 1.12.4

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 (51) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +16 -16
  3. package/dist/plan-format.d.ts +23 -11
  4. package/dist/plan-format.js +6 -3
  5. package/herdr-plugin/herdr-plugin.toml +1 -1
  6. package/install.sh +1 -1
  7. package/kit/workflow/skills/orchestrate/SKILL.md +7 -7
  8. package/package.json +1 -1
  9. package/src/context/session-log.ts +4 -6
  10. package/src/plan-format/phases.ts +22 -6
  11. package/src/plan-format/progress.ts +5 -8
  12. package/src/plans/command.ts +10 -3
  13. package/src/plans/current-task.ts +3 -7
  14. package/src/plans/focus-overlay.ts +80 -0
  15. package/src/plans/git.ts +6 -11
  16. package/src/plans/herd-state.ts +201 -0
  17. package/src/plans/lane-reason.ts +1 -1
  18. package/src/plans/lane-summary.ts +6 -2
  19. package/src/plans/layout-phase.ts +1 -3
  20. package/src/plans/layout-summary.ts +1 -1
  21. package/src/plans/layout.ts +32 -31
  22. package/src/plans/link-chips.ts +7 -16
  23. package/src/plans/live.ts +215 -267
  24. package/src/plans/markdown-text.ts +4 -0
  25. package/src/plans/page-sync.ts +39 -0
  26. package/src/plans/phase-source.ts +5 -2
  27. package/src/plans/reader.ts +217 -0
  28. package/src/plans/refresh-schedule.ts +53 -0
  29. package/src/plans/snapshot.ts +32 -31
  30. package/src/plans/status-pace.ts +7 -4
  31. package/src/plans/subagents-read.ts +28 -12
  32. package/src/plans/supervisor.ts +1 -1
  33. package/src/plans/view.ts +9 -101
  34. package/src/plans/waiting-click.ts +27 -17
  35. package/src/plans/worked-phases.ts +1 -1
  36. package/src/plans/worktree-stamp.ts +12 -9
  37. package/src/plans/your-turn-rows.ts +33 -32
  38. package/src/plans/your-turn.ts +6 -4
  39. package/src/shared/bounded-memo.ts +19 -0
  40. package/src/shared/stat-cache.ts +2 -1
  41. package/src/terminal/lifetime.ts +52 -0
  42. package/src/watch/command.ts +21 -10
  43. package/src/watch/herd-publisher.ts +84 -0
  44. package/src/watch/lane-report.ts +17 -1
  45. package/src/watch/source-entries.ts +7 -0
  46. package/src/watch/source-statuses.ts +2 -2
  47. package/src/watch/sources.ts +5 -2
  48. package/src/watch/stream-output.ts +6 -0
  49. package/src/watch/watch-events.ts +8 -0
  50. package/src/watch/watcher-lock.ts +5 -2
  51. package/src/plans/next-rows.ts +0 -12
@@ -0,0 +1,84 @@
1
+ import { rmSync } from 'node:fs'
2
+ import { TERMINAL_ADAPTER } from '../config/schema'
3
+ import { errorText } from '../plans/error-text'
4
+ import {
5
+ type HerdRead,
6
+ herdFilesOf,
7
+ publishDecision,
8
+ publishedKey,
9
+ storedRead,
10
+ type Written,
11
+ writeHerdFile,
12
+ } from '../plans/herd-state'
13
+ import { initialPageState, syncSprintPage } from '../plans/page-sync'
14
+ import { startReader } from '../plans/reader'
15
+ import type { PlansSettings } from '../plans/settings'
16
+ import type { Snapshot, Source } from '../plans/snapshot'
17
+ import { laneStatusSourceFor } from '../terminal'
18
+ import { listHerdrAgents } from '../terminal/herdr'
19
+ import type { StartedKit } from '../version/started-kit'
20
+
21
+ /** What the publisher reads for: the watcher's checkout, board and settings. */
22
+ export type PublishedBoard = { root: string; sprintFile: string; settings: PlansSettings }
23
+
24
+ /** The board read as a herd without a lane reads it, with the terminal's agents read without holding the event loop. */
25
+ const sourceOf = ({ root, sprintFile, settings }: PublishedBoard): Source => ({
26
+ root,
27
+ sprintFile,
28
+ options: { boardPath: null, lane: null, once: false, ensure: false },
29
+ settings,
30
+ terminal: laneStatusSourceFor(settings.adapter),
31
+ ...(settings.adapter === TERMINAL_ADAPTER.herdr ? { listAgents: () => listHerdrAgents() } : {}),
32
+ })
33
+
34
+ /**
35
+ * Reads the board once for every herd of it and publishes the read (`herd-state.ts`): the read file only when its
36
+ * content changed, the plan texts only when they did, and the beat after every read that worked, so a herd reads for
37
+ * itself again once the watcher stops or fails for longer than `PUBLISHED_MAX_AGE_MS`. It writes the sprint page as
38
+ * well, so no herd builds it. Returns the stop.
39
+ */
40
+ export const startHerdPublisher = (board: PublishedBoard, kit: StartedKit): (() => void) => {
41
+ const source = sourceOf(board)
42
+ const key = publishedKey(board, kit)
43
+ const files = herdFilesOf(board.root)
44
+ const pageState = initialPageState()
45
+ const written: Written = { content: null, plansKey: null }
46
+ let last: { snapshot: Snapshot; fetchLine: string | null } | null = null
47
+ let stopped = false
48
+
49
+ /** Writes what changed and the beat; a failed write writes no beat, so the herds read for themselves soon. */
50
+ const publish = (): boolean => {
51
+ if (stopped || !last) return false
52
+ const { snapshot, fetchLine } = last
53
+ const stored: HerdRead = storedRead({ snapshot, page: pageState.page, fetchLine }, key, Date.now())
54
+ const { writePlans, writeRead, content } = publishDecision(written, stored)
55
+ try {
56
+ if (writePlans) writeHerdFile(files.plans, { plansKey: stored.plansKey, plans: snapshot.documents.plans })
57
+ written.plansKey = stored.plansKey
58
+ if (writeRead) writeHerdFile(files.read, stored)
59
+ written.content = content
60
+ writeHerdFile(files.beat, { key, at: stored.at })
61
+ } catch (error) {
62
+ console.log(`herd read not published: ${errorText(error)}`)
63
+ return false
64
+ }
65
+ return writeRead
66
+ }
67
+
68
+ // A beat of an earlier watcher must not vouch for its read until this one has read.
69
+ rmSync(files.beat, { force: true })
70
+ const reader = startReader(source, (outcome) => {
71
+ if ('error' in outcome) return false
72
+ last = { snapshot: outcome.read, fetchLine: reader.fetchLine() }
73
+ const changed = publish()
74
+ const { snapshot } = last
75
+ setTimeout(async () => {
76
+ if (await syncSprintPage(pageState, source, snapshot, async () => snapshot)) publish()
77
+ }, 0)
78
+ return changed
79
+ })
80
+ return () => {
81
+ stopped = true
82
+ reader.stop()
83
+ }
84
+ }
@@ -25,7 +25,7 @@ import {
25
25
  WATCH_EVENT,
26
26
  type WatchEvent,
27
27
  } from './watch-events'
28
- import type { TakeOver } from './watcher-lock'
28
+ import { NO_TAKE_OVER, type TakeOver } from './watcher-lock'
29
29
 
30
30
  export type LaneReport = {
31
31
  lane: string
@@ -363,3 +363,19 @@ export const armLine = (lanes: readonly string[], armed: LaneSnapshot, takeOver:
363
363
  'no lanes'
364
364
  return `armed: ${states}${takeOverNote(takeOver)} board ${terminalSafe(board)}`
365
365
  }
366
+
367
+ /**
368
+ * The `armed:` line a start prints, null when it would repeat the last start's (`previous`), and the line to remember:
369
+ * the one without a take-over note, so a start that replaced a watcher speaks and the one after it compares as usual.
370
+ */
371
+ export const armNotice = (
372
+ previous: string | null,
373
+ lanes: readonly string[],
374
+ armed: LaneSnapshot,
375
+ takeOver: TakeOver,
376
+ board: string,
377
+ ): { line: string | null; state: string } => {
378
+ const state = armLine(lanes, armed, NO_TAKE_OVER, board)
379
+ const line = armLine(lanes, armed, takeOver, board)
380
+ return { line: line === previous ? null : line, state }
381
+ }
@@ -16,6 +16,12 @@ export const ENTRY_LIMIT = 20
16
16
 
17
17
  const shownAt = ({ changedAt, seenAt }: SourceEntry): number => changedAt ?? seenAt
18
18
 
19
+ /** The source's own time of an entry in epoch milliseconds; null for an unmapped `updatedAt` (`''`) or no date. */
20
+ const sourceTimeOf = (updatedAt: string): number | null => {
21
+ const time = updatedAt === '' ? Number.NaN : Date.parse(updatedAt)
22
+ return Number.isNaN(time) ? null : time
23
+ }
24
+
19
25
  const entryOf = (
20
26
  item: SourceItem,
21
27
  known: SourceEntry | undefined,
@@ -29,6 +35,7 @@ const entryOf = (
29
35
  count: item.count,
30
36
  seenAt: known?.seenAt ?? now,
31
37
  changedAt: seen && reportsItem(seen, item, settings) ? now : (known?.changedAt ?? null),
38
+ updatedAt: sourceTimeOf(item.updatedAt),
32
39
  })
33
40
 
34
41
  /**
@@ -6,7 +6,7 @@ import type { WatchState } from './watch-events'
6
6
  /** The newest entry a source reported, as the herd draws it; `isNew` while it is younger than `NEW_FOR_MS`. */
7
7
  export type LatestStatus = { title: string; url: string | null; isNew: boolean }
8
8
 
9
- /** An entry as the herd lists it under its source: `at` is when it last changed, else when the watcher first saw it; `isNew` only for a reported change younger than `NEW_FOR_MS`. */
9
+ /** An entry as the herd lists it under its source: `at` is the source's own time of it, else when it last changed, else when the watcher first saw it; `isNew` only for a reported change younger than `NEW_FOR_MS`. */
10
10
  export type EntryStatus = {
11
11
  id: string
12
12
  title: string
@@ -30,7 +30,7 @@ const entryStatusOf = (entry: SourceEntry, now: number): EntryStatus => ({
30
30
  title: lineText(entry.title),
31
31
  url: entry.url === null ? null : previewUrlOf(entry.url, undefined),
32
32
  count: entry.count,
33
- at: entry.changedAt ?? entry.seenAt,
33
+ at: entry.updatedAt ?? entry.changedAt ?? entry.seenAt,
34
34
  isNew: entry.changedAt !== null && now - entry.changedAt < NEW_FOR_MS,
35
35
  })
36
36
 
@@ -93,7 +93,8 @@ export type LatestReport = { title: string; url: string | null; at: number }
93
93
 
94
94
  /**
95
95
  * An entry of a source as the herd lists it: its title as one line, its checked URL, its count, when the watcher first
96
- * saw it (`seenAt`) and when it last reported a change of it (`changedAt`, null while only the baseline knows it).
96
+ * saw it (`seenAt`), when it last reported a change of it (`changedAt`, null while only the baseline knows it), and the
97
+ * source's own time of it (`map.updatedAt` read as a date; null when unmapped or no date, absent in older states).
97
98
  */
98
99
  export type SourceEntry = {
99
100
  id: string
@@ -102,6 +103,7 @@ export type SourceEntry = {
102
103
  count: string | null
103
104
  seenAt: number
104
105
  changedAt: number | null
106
+ updatedAt?: number | null
105
107
  }
106
108
 
107
109
  /**
@@ -419,7 +421,8 @@ const isSourceEntry = (value: unknown): boolean =>
419
421
  isTextOrNull(value.url) &&
420
422
  isTextOrNull(value.count) &&
421
423
  typeof value.seenAt === 'number' &&
422
- (value.changedAt === null || typeof value.changedAt === 'number')
424
+ (value.changedAt === null || typeof value.changedAt === 'number') &&
425
+ (value.updatedAt === undefined || value.updatedAt === null || typeof value.updatedAt === 'number')
423
426
 
424
427
  const isSourceRecord = (value: unknown): boolean =>
425
428
  isRecord(value) &&
@@ -35,3 +35,9 @@ export const streamHeartbeatLine = (
35
35
  ...standing.map((line) => terminalSafe(line).replaceAll(SEPARATOR, ' / ')),
36
36
  ].join(SEPARATOR)
37
37
  }
38
+
39
+ /** A heartbeat line without its head and time: what two heartbeats are compared by. */
40
+ export const heartbeatStateOf = (line: string): string => line.slice(line.indexOf(SEPARATOR) + SEPARATOR.length)
41
+
42
+ /** Whether a heartbeat brings news against the state of the last one sent (`heartbeatStateOf`); the first one always does. */
43
+ export const isHeartbeatNews = (previous: string | null, line: string): boolean => previous !== heartbeatStateOf(line)
@@ -128,6 +128,10 @@ export type WatchState = {
128
128
  reportQuestions: Record<string, string>
129
129
  /** The lane lines of the status block last printed; null before the first report. */
130
130
  statusLines: string[] | null
131
+ /** The state of the last heartbeat a stream sent (`heartbeatStateOf`); null before the first one. */
132
+ heartbeat: string | null
133
+ /** The `armed:` line of the last start; a start that would print the same line stays silent. Null before the first. */
134
+ armed: string | null
131
135
  /** The board's appointments reported as due and its unreadable appointment rows reported, each once (`appointments.ts`). */
132
136
  appointments: AppointmentState
133
137
  }
@@ -219,6 +223,8 @@ export const emptyWatchState = (): WatchState => ({
219
223
  reportMtimes: {},
220
224
  reportQuestions: {},
221
225
  statusLines: null,
226
+ heartbeat: null,
227
+ armed: null,
222
228
  appointments: emptyAppointmentState(),
223
229
  })
224
230
 
@@ -271,6 +277,8 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
271
277
  everyValue(value.reportQuestions, (entry) => typeof entry === 'string') &&
272
278
  (value.statusLines === null ||
273
279
  (Array.isArray(value.statusLines) && value.statusLines.every((line) => typeof line === 'string'))) &&
280
+ (value.heartbeat === null || typeof value.heartbeat === 'string') &&
281
+ (value.armed === null || typeof value.armed === 'string') &&
274
282
  isAppointmentState(value.appointments)
275
283
 
276
284
  /**
@@ -18,6 +18,9 @@ export type CommandLineOf = (pid: number) => string | null
18
18
  /** What taking the lock did to a watcher that held it: stopped it, or found it still running after the wait. */
19
19
  export type TakeOver = { replaced: number | null; stillRunning: number | null }
20
20
 
21
+ /** A start that found no watcher to take over from. */
22
+ export const NO_TAKE_OVER: TakeOver = { replaced: null, stillRunning: null }
23
+
21
24
  /** The CLI's entry by source path or bin link (`jh`, and `jackherd` for `bunx jackherd`); `agent-kit` until 2.0.0, so a watcher of the old CLI is replaced. */
22
25
  const CLI_ENTRIES = ['cli.ts', CLI_NAME, PACKAGE_NAME, 'agent-kit']
23
26
  const WATCH_COMMAND = 'watch'
@@ -165,11 +168,11 @@ export const isWatcherRunning = (lockFile: string, lookUp: CommandLineOf = reade
165
168
  export const takeOverLock = async (lockFile: string): Promise<TakeOver> => {
166
169
  const previous = supersedeTarget(readPrivateText(lockFile), process.pid, commandLineOf)
167
170
  writePrivateFile(lockFile, String(process.pid))
168
- if (previous === null || !isAlive(previous)) return { replaced: null, stillRunning: null }
171
+ if (previous === null || !isAlive(previous)) return NO_TAKE_OVER
169
172
  try {
170
173
  process.kill(previous, 'SIGTERM')
171
174
  } catch {
172
- return { replaced: null, stillRunning: null }
175
+ return NO_TAKE_OVER
173
176
  }
174
177
  const deadline = Date.now() + STOP_WAIT_MS
175
178
  while (isAlive(previous) && Date.now() < deadline) await Bun.sleep(STOP_POLL_MS)
@@ -1,12 +0,0 @@
1
- import { sectionLines } from '../plan-format/markdown'
2
- import { NEXT_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
-
4
- const MAX_NEXT_LINES = 3
5
-
6
- const LIST_LINE = /^(?:\d+[.)]|[-*])\s+(.+)$/
7
-
8
- /** The board's next section, up to three list lines in the order written; the parser cuts after three, as the board says. */
9
- export const nextLines = (board: string, language: PlanLanguage): string[] =>
10
- sectionLines(board, NEXT_VOCABULARY[language].heading)
11
- .flatMap((line) => LIST_LINE.exec(line.trim())?.[1] ?? [])
12
- .slice(0, MAX_NEXT_LINES)