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.
- package/CHANGELOG.md +16 -0
- package/README.md +16 -16
- package/dist/plan-format.d.ts +23 -11
- package/dist/plan-format.js +6 -3
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +7 -7
- package/package.json +1 -1
- package/src/context/session-log.ts +4 -6
- package/src/plan-format/phases.ts +22 -6
- package/src/plan-format/progress.ts +5 -8
- package/src/plans/command.ts +10 -3
- package/src/plans/current-task.ts +3 -7
- package/src/plans/focus-overlay.ts +80 -0
- package/src/plans/git.ts +6 -11
- package/src/plans/herd-state.ts +201 -0
- package/src/plans/lane-reason.ts +1 -1
- package/src/plans/lane-summary.ts +6 -2
- package/src/plans/layout-phase.ts +1 -3
- package/src/plans/layout-summary.ts +1 -1
- package/src/plans/layout.ts +32 -31
- package/src/plans/link-chips.ts +7 -16
- package/src/plans/live.ts +215 -267
- package/src/plans/markdown-text.ts +4 -0
- package/src/plans/page-sync.ts +39 -0
- package/src/plans/phase-source.ts +5 -2
- package/src/plans/reader.ts +217 -0
- package/src/plans/refresh-schedule.ts +53 -0
- package/src/plans/snapshot.ts +32 -31
- package/src/plans/status-pace.ts +7 -4
- package/src/plans/subagents-read.ts +28 -12
- package/src/plans/supervisor.ts +1 -1
- package/src/plans/view.ts +9 -101
- package/src/plans/waiting-click.ts +27 -17
- package/src/plans/worked-phases.ts +1 -1
- package/src/plans/worktree-stamp.ts +12 -9
- package/src/plans/your-turn-rows.ts +33 -32
- package/src/plans/your-turn.ts +6 -4
- package/src/shared/bounded-memo.ts +19 -0
- package/src/shared/stat-cache.ts +2 -1
- package/src/terminal/lifetime.ts +52 -0
- package/src/watch/command.ts +21 -10
- package/src/watch/herd-publisher.ts +84 -0
- package/src/watch/lane-report.ts +17 -1
- package/src/watch/source-entries.ts +7 -0
- package/src/watch/source-statuses.ts +2 -2
- package/src/watch/sources.ts +5 -2
- package/src/watch/stream-output.ts +6 -0
- package/src/watch/watch-events.ts +8 -0
- package/src/watch/watcher-lock.ts +5 -2
- 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
|
+
}
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
WATCH_EVENT,
|
|
26
26
|
type WatchEvent,
|
|
27
27
|
} from './watch-events'
|
|
28
|
-
import type
|
|
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
|
|
package/src/watch/sources.ts
CHANGED
|
@@ -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`)
|
|
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
|
|
171
|
+
if (previous === null || !isAlive(previous)) return NO_TAKE_OVER
|
|
169
172
|
try {
|
|
170
173
|
process.kill(previous, 'SIGTERM')
|
|
171
174
|
} catch {
|
|
172
|
-
return
|
|
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)
|
package/src/plans/next-rows.ts
DELETED
|
@@ -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)
|