jackherd 1.9.0 → 1.10.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.
- package/CHANGELOG.md +30 -0
- package/INSTALL.md +1 -1
- package/README.md +50 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
- package/dist/config.d.ts +39 -4
- package/dist/config.js +67 -26
- package/dist/plan-format.d.ts +45 -6
- package/dist/plan-format.js +46 -6
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +59 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +1 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +58 -24
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +7 -4
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +7 -2
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +29 -7
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +262 -68
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +107 -20
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +98 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +85 -62
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +23 -6
- package/src/plans/view.ts +17 -9
- package/src/plans/waiting-click.ts +192 -37
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +8 -3
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +16 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/sources.ts +6 -3
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
package/src/plans/live.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { existsSync, type FSWatcher, watch } from 'node:fs'
|
|
2
|
-
import { dirname } from 'node:path'
|
|
2
|
+
import { basename, dirname } from 'node:path'
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
4
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
|
+
import { runHerdr } from '../terminal/herdr'
|
|
5
6
|
import { openInBrowser } from '../terminal/open'
|
|
7
|
+
import { appointmentKeys } from './appointment-rows'
|
|
8
|
+
import { askCoordinator } from './ask-coordinator'
|
|
6
9
|
import type { LaneMemory } from './current-phase'
|
|
7
10
|
import { errorText } from './error-text'
|
|
8
11
|
import {
|
|
9
12
|
type FetchState,
|
|
10
13
|
fetchBase,
|
|
11
14
|
fetchNotice,
|
|
15
|
+
fetchPlanHeads,
|
|
12
16
|
goneLanePullRequests,
|
|
13
17
|
initialFetchState,
|
|
14
18
|
shouldFetch,
|
|
@@ -19,16 +23,24 @@ import { fetchPullRequests, noPullRequests, type PullRequestState } from './gith
|
|
|
19
23
|
import { recallLanes, rememberLanes } from './herd-memory'
|
|
20
24
|
import type { Screen } from './layout'
|
|
21
25
|
import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
|
|
26
|
+
import { planPullRequests } from './plan-heads'
|
|
22
27
|
import { herdLabel } from './plan-panes'
|
|
23
28
|
import { pullRequestUrl } from './pull-requests'
|
|
29
|
+
import { newReadCache, type ReadCache } from './read-cache'
|
|
30
|
+
import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
|
|
24
31
|
import {
|
|
25
32
|
inputOutcome,
|
|
26
33
|
isSprintPageKey,
|
|
27
34
|
overviewSelection,
|
|
28
35
|
parseMouse,
|
|
36
|
+
settledBlockFolds,
|
|
37
|
+
showDoneNotice,
|
|
29
38
|
splitKeys,
|
|
30
39
|
syncLaneFolds,
|
|
40
|
+
TOP_BLOCK,
|
|
41
|
+
type TopBlockId,
|
|
31
42
|
targetKeys,
|
|
43
|
+
toggledBlockFold,
|
|
32
44
|
toggledExpansion,
|
|
33
45
|
type View,
|
|
34
46
|
windowTop,
|
|
@@ -38,8 +50,18 @@ import { singleFlight } from './single-flight'
|
|
|
38
50
|
import { loadSnapshot, type Snapshot, type Source } from './snapshot'
|
|
39
51
|
import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
|
|
40
52
|
import { boardTexts } from './texts'
|
|
41
|
-
import {
|
|
42
|
-
|
|
53
|
+
import {
|
|
54
|
+
bodyRows,
|
|
55
|
+
clockOf,
|
|
56
|
+
frameOutput,
|
|
57
|
+
frameToWrite,
|
|
58
|
+
paneWidth,
|
|
59
|
+
renderSnapshot,
|
|
60
|
+
startSelection,
|
|
61
|
+
TERMINAL_OFF,
|
|
62
|
+
TERMINAL_ON,
|
|
63
|
+
} from './view'
|
|
64
|
+
import { type BlockClick, type BlockHit, blockClick, blockHeadClick, waitingKeys } from './waiting-click'
|
|
43
65
|
|
|
44
66
|
/** The state of the running view; only the functions below `runLive` change it. */
|
|
45
67
|
type Live = {
|
|
@@ -58,6 +80,8 @@ type Live = {
|
|
|
58
80
|
reported: Map<string, string | null>
|
|
59
81
|
/** Per lane, the ticks seen at the last read, the phase that moved last and when each moved; kept across herds (`rememberLanes`). */
|
|
60
82
|
memories: Map<string, LaneMemory>
|
|
83
|
+
/** Session logs and plans parsed at the last read, kept while they did not change. */
|
|
84
|
+
cache: ReadCache
|
|
61
85
|
pending: Timer | null
|
|
62
86
|
/** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
|
|
63
87
|
read: Snapshot | null
|
|
@@ -66,6 +90,8 @@ type Live = {
|
|
|
66
90
|
pageWriting: boolean
|
|
67
91
|
flight: (() => Promise<void>) | null
|
|
68
92
|
pageError: string | null
|
|
93
|
+
/** The frame last written to the terminal; null while its content is unknown, such as after a resize. */
|
|
94
|
+
written: string | null
|
|
69
95
|
}
|
|
70
96
|
|
|
71
97
|
const REFRESH_MS = 5_000
|
|
@@ -143,7 +169,10 @@ const redraw = (live: Live) => {
|
|
|
143
169
|
const top = windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen))
|
|
144
170
|
live.screen = screen
|
|
145
171
|
live.view = { ...live.view, top }
|
|
146
|
-
|
|
172
|
+
const output = frameToWrite(live.written, frameOutput(screen, top, paneWidth(), bodyRows(screen)))
|
|
173
|
+
if (output === null) return
|
|
174
|
+
live.written = output
|
|
175
|
+
process.stdout.write(output)
|
|
147
176
|
}
|
|
148
177
|
|
|
149
178
|
/** The whole sprint, also while the view shows one lane: the snapshot is read again without the lane filter. */
|
|
@@ -193,16 +222,27 @@ const refresh = (live: Live, onChange: () => void): Promise<void> => {
|
|
|
193
222
|
|
|
194
223
|
const readOnce = async (live: Live, onChange: () => void) => {
|
|
195
224
|
try {
|
|
196
|
-
const read = await loadSnapshot(live.source, live.pullRequests, live.memories)
|
|
225
|
+
const read = await loadSnapshot(live.source, live.pullRequests, live.memories, live.cache)
|
|
197
226
|
rememberLanes(live.source.root, live.memories, read.documents.laneOrder)
|
|
198
227
|
const next = withKnownPage(live, read)
|
|
199
228
|
live.read = read
|
|
200
|
-
if (!live.snapshot)
|
|
229
|
+
if (!live.snapshot)
|
|
230
|
+
live.view = {
|
|
231
|
+
...live.view,
|
|
232
|
+
selection: startSelection(next, live.source.options),
|
|
233
|
+
}
|
|
201
234
|
const folded = syncLaneFolds(
|
|
202
235
|
live.view.selection,
|
|
203
|
-
next.views.map(({ progress, status }) => ({
|
|
236
|
+
next.views.map(({ progress, status }) => ({
|
|
237
|
+
lane: progress.lane.name,
|
|
238
|
+
status: status.agent,
|
|
239
|
+
})),
|
|
204
240
|
)
|
|
205
|
-
|
|
241
|
+
const settled = settledBlockFolds(folded, {
|
|
242
|
+
waiting: waitingKeys(next.waiting ?? []),
|
|
243
|
+
appointments: appointmentKeys(next.appointments?.rows ?? []),
|
|
244
|
+
})
|
|
245
|
+
live.view = { ...live.view, selection: settled }
|
|
206
246
|
live.snapshot = next
|
|
207
247
|
const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), CLOCK_LOCALE)
|
|
208
248
|
live.errors = [
|
|
@@ -246,18 +286,50 @@ const openPlan = (live: Live, snapshot: Snapshot, address: string) => {
|
|
|
246
286
|
live.notice = reason === null ? texts.pageOpened(live.page.path) : texts.pageNotOpenedReason(reason)
|
|
247
287
|
}
|
|
248
288
|
|
|
249
|
-
/**
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (!outcome) return false
|
|
289
|
+
/**
|
|
290
|
+
* What a hit on a row of a block of the herd does: expands or folds it, asks the coordinator to put a question or a board row
|
|
291
|
+
* (the result shows in the footer) or opens its address (a failure shows there). An appointment row only ever expands.
|
|
292
|
+
*/
|
|
293
|
+
const applyBlockClick = (live: Live, outcome: BlockClick): void => {
|
|
255
294
|
if ('toggle' in outcome) {
|
|
256
|
-
live.view = {
|
|
257
|
-
|
|
295
|
+
live.view = {
|
|
296
|
+
...live.view,
|
|
297
|
+
selection: toggledExpansion(live.view.selection, outcome.toggle),
|
|
298
|
+
}
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
if ('ask' in outcome) {
|
|
302
|
+
const { root, settings } = live.source
|
|
303
|
+
live.notice = askCoordinator(outcome.ask, {
|
|
304
|
+
language: settings.responseLanguage,
|
|
305
|
+
adapter: settings.adapter,
|
|
306
|
+
repository: basename(root),
|
|
307
|
+
run: runHerdr,
|
|
308
|
+
})
|
|
309
|
+
return
|
|
258
310
|
}
|
|
259
311
|
const reason = openInBrowser(outcome.open)
|
|
260
312
|
if (reason) live.notice = boardTextsOf(live).notOpened(terminalSafe(reason))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head folds or opens it, a
|
|
317
|
+
* cut-off row expands, a file or Artifact opens, a question or a board row asks the coordinator; the failure or the result
|
|
318
|
+
* shows in the footer. An appointment row has nothing to open or ask.
|
|
319
|
+
*/
|
|
320
|
+
const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input: string): boolean => {
|
|
321
|
+
const mouse = parseMouse(input)
|
|
322
|
+
if (!mouse || !hit) return false
|
|
323
|
+
if (blockHeadClick(hit.firstLine - 1, mouse)) {
|
|
324
|
+
live.view = {
|
|
325
|
+
...live.view,
|
|
326
|
+
selection: toggledBlockFold(live.view.selection, block, hit.keys),
|
|
327
|
+
}
|
|
328
|
+
return true
|
|
329
|
+
}
|
|
330
|
+
const outcome = blockClick(hit.rows, hit.firstLine, mouse, hit.moreKey)
|
|
331
|
+
if (!outcome) return false
|
|
332
|
+
applyBlockClick(live, outcome)
|
|
261
333
|
return true
|
|
262
334
|
}
|
|
263
335
|
|
|
@@ -269,7 +341,10 @@ const handleInput = (live: Live, input: string) => {
|
|
|
269
341
|
void writeSprintPage(live, snapshot)
|
|
270
342
|
return
|
|
271
343
|
}
|
|
272
|
-
if (
|
|
344
|
+
if (
|
|
345
|
+
clickBlock(live, TOP_BLOCK.waiting, screen.waitingBlock, input) ||
|
|
346
|
+
clickBlock(live, TOP_BLOCK.appointments, screen.appointmentBlock, input)
|
|
347
|
+
) {
|
|
273
348
|
redraw(live)
|
|
274
349
|
return
|
|
275
350
|
}
|
|
@@ -279,6 +354,7 @@ const handleInput = (live: Live, input: string) => {
|
|
|
279
354
|
headerRows: screen.header.length,
|
|
280
355
|
bodyRows: bodyRows(screen),
|
|
281
356
|
}
|
|
357
|
+
live.notice = showDoneNotice(live.view.selection, input, context.keys)
|
|
282
358
|
const pullRequests = snapshot.views.flatMap((view) => view.pullRequests)
|
|
283
359
|
const outcome = inputOutcome(live.view, input, context, (key) => pullRequestUrl(pullRequests, key))
|
|
284
360
|
live.view = outcome.view
|
|
@@ -306,12 +382,14 @@ export const runLive = (source: Source): void => {
|
|
|
306
382
|
fetchHead: fetchHeadPath(source.root),
|
|
307
383
|
reported: new Map(),
|
|
308
384
|
memories: recallLanes(source.root),
|
|
385
|
+
cache: newReadCache(),
|
|
309
386
|
pending: null,
|
|
310
387
|
read: null,
|
|
311
388
|
page: null,
|
|
312
389
|
pageWriting: false,
|
|
313
390
|
flight: null,
|
|
314
391
|
pageError: null,
|
|
392
|
+
written: null,
|
|
315
393
|
}
|
|
316
394
|
const schedule = () => {
|
|
317
395
|
if (live.pending) clearTimeout(live.pending)
|
|
@@ -320,6 +398,8 @@ export const runLive = (source: Source): void => {
|
|
|
320
398
|
const fetchBaseBranch = async () => {
|
|
321
399
|
live.fetch = { ...live.fetch, lastAttempt: Date.now(), running: true }
|
|
322
400
|
const failure = await fetchBase(source.root, BASE_REMOTE, source.settings.baseBranch)
|
|
401
|
+
const planHeads = planPullRequests([...live.pullRequests.byBranch.values()].flat(), source.settings.planDirectory)
|
|
402
|
+
if (failure === null) await fetchPlanHeads(source.root, BASE_REMOTE, planHeads)
|
|
323
403
|
live.fetch = {
|
|
324
404
|
...live.fetch,
|
|
325
405
|
running: false,
|
|
@@ -346,17 +426,24 @@ export const runLive = (source: Source): void => {
|
|
|
346
426
|
fetchWhenDue(readable && gone.length > 0)
|
|
347
427
|
schedule()
|
|
348
428
|
}
|
|
349
|
-
const
|
|
429
|
+
const leave = (code: number) => {
|
|
350
430
|
clearTitles(source, live.reported)
|
|
351
431
|
source.terminal.labelOwnPane(null)
|
|
352
432
|
for (const watcher of live.watchers.values()) watcher.close()
|
|
353
433
|
process.stdout.write(TERMINAL_OFF)
|
|
354
|
-
process.exit(
|
|
434
|
+
process.exit(code)
|
|
355
435
|
}
|
|
436
|
+
const quit = () => leave(0)
|
|
437
|
+
const takenOver = () => leave(TAKEN_OVER_CODE)
|
|
356
438
|
const onInput = (input: string) => (QUIT_KEYS.has(input) ? quit() : handleInput(live, input))
|
|
357
439
|
|
|
358
440
|
source.terminal.labelOwnPane(herdLabel(source.options.lane))
|
|
359
|
-
|
|
441
|
+
process.removeAllListeners(TAKEOVER_SIGNAL)
|
|
442
|
+
process.on(TAKEOVER_SIGNAL, takenOver)
|
|
443
|
+
enterTerminal(onInput, quit, () => {
|
|
444
|
+
live.written = null
|
|
445
|
+
redraw(live)
|
|
446
|
+
})
|
|
360
447
|
setInterval(() => {
|
|
361
448
|
fetchWhenDue(false)
|
|
362
449
|
void refresh(live, schedule)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const SUB_PHASE_LETTERS = /[a-z]+$/i
|
|
2
|
+
|
|
3
|
+
/** The main phase a sub-phase belongs to: `10c` is part of `10`; a main phase is its own. */
|
|
4
|
+
export const mainPhaseIdOf = (id: string): string => id.replace(SUB_PHASE_LETTERS, '')
|
|
5
|
+
|
|
6
|
+
/** Whether `named` names the phase: by its own id, or by its main phase when it is a sub-phase (`21b` by `21`). */
|
|
7
|
+
export const isNamedBy = (id: string, named: ReadonlySet<string>): boolean =>
|
|
8
|
+
named.has(id) || named.has(mainPhaseIdOf(id))
|
|
@@ -35,6 +35,21 @@ const tickCount = (phase: PlanPhase): number => phase.items.filter((item) => ite
|
|
|
35
35
|
const tickedFurther = (worktreePhase: PlanPhase, basePhase: PlanPhase): boolean =>
|
|
36
36
|
tickCount(worktreePhase) > tickCount(basePhase)
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The phases of `base` with each phase replaced by the copy's where the copy ticked it further (`tickedFurther`), and
|
|
40
|
+
* the phases only the copy has after them. Nothing is marked touched: the copy is someone's branch, not the lane's own
|
|
41
|
+
* work, so it never makes a phase the lane's current one.
|
|
42
|
+
*/
|
|
43
|
+
export const newerPhases = (base: readonly PlanPhase[], copy: readonly PlanPhase[]): PlanPhase[] => {
|
|
44
|
+
const copyById = new Map(copy.map((phase) => [phase.id, phase]))
|
|
45
|
+
const baseIds = new Set(base.map((phase) => phase.id))
|
|
46
|
+
const inBaseOrder = base.map((basePhase) => {
|
|
47
|
+
const copied = copyById.get(basePhase.id)
|
|
48
|
+
return copied && tickedFurther(copied, basePhase) ? copied : basePhase
|
|
49
|
+
})
|
|
50
|
+
return [...inBaseOrder, ...copy.filter((phase) => !baseIds.has(phase.id))]
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
/** A phase read from the worktree is touched when its ticks differ from the base's, or the base lacks it. */
|
|
39
54
|
const markTouched = (phase: PlanPhase, basePhase: PlanPhase | undefined): PlanPhase =>
|
|
40
55
|
basePhase && sameTicks(phase, basePhase) ? phase : { ...phase, touched: true }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type PlanLanguage, type PlanPhase, parsePlanPhases } from '../plan-format'
|
|
2
|
+
import type { CacheRound } from '../shared/stat-cache'
|
|
3
|
+
|
|
4
|
+
type ParsedPlan = { text: string; language: PlanLanguage; phases: PlanPhase[] }
|
|
5
|
+
|
|
6
|
+
/** The phases of each plan by key, kept while the plan's text and language stay the same. */
|
|
7
|
+
export type PlanCache = Map<string, ParsedPlan>
|
|
8
|
+
|
|
9
|
+
export type PlanRound = CacheRound<ParsedPlan>
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `parsePlanPhases` of the text, asked again only when the text or the language under `key` changed since the cache
|
|
13
|
+
* saw it. The text decides, not a modification time: most plans come from the base ref, which has none.
|
|
14
|
+
*/
|
|
15
|
+
export const parsedPlanPhases = (
|
|
16
|
+
round: PlanRound | undefined,
|
|
17
|
+
key: string,
|
|
18
|
+
text: string,
|
|
19
|
+
language: PlanLanguage,
|
|
20
|
+
): PlanPhase[] => {
|
|
21
|
+
if (!round) return parsePlanPhases(text, language)
|
|
22
|
+
round.used.add(key)
|
|
23
|
+
const kept = round.entries.get(key)
|
|
24
|
+
if (kept && kept.language === language && kept.text === text) return kept.phases
|
|
25
|
+
const phases = parsePlanPhases(text, language)
|
|
26
|
+
round.entries.set(key, { text, language, phases })
|
|
27
|
+
return phases
|
|
28
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { BaseReader } from './git'
|
|
2
|
+
import type { PullRequest } from './pull-requests'
|
|
3
|
+
|
|
4
|
+
/** What choosing the heads needs of an open pull request: the herd's and the watcher's lists both carry it. */
|
|
5
|
+
export type PlanPullRequest = Pick<PullRequest, 'number' | 'branch' | 'files'>
|
|
6
|
+
|
|
7
|
+
/** An open pull request that changes a plan, with a reader of the files at its head. */
|
|
8
|
+
export type PlanHead = { number: number; files: readonly string[]; read: BaseReader }
|
|
9
|
+
|
|
10
|
+
/** Where the herd keeps the fetched heads of open pull requests: its own namespace, apart from branches and remotes. */
|
|
11
|
+
const PULL_HEAD_NAMESPACE = 'refs/jackherd/pr'
|
|
12
|
+
|
|
13
|
+
export const pullHeadRef = (number: number): string => `${PULL_HEAD_NAMESPACE}/${number}`
|
|
14
|
+
|
|
15
|
+
/** The refspec that fetches a pull request's head from `refs/pull/<n>/head`, which GitHub keeps for forks too. */
|
|
16
|
+
export const pullHeadRefspec = (number: number): string => `+refs/pull/${number}/head:${pullHeadRef(number)}`
|
|
17
|
+
|
|
18
|
+
const changesPlan = (pullRequest: PlanPullRequest, planDirectory: string): boolean =>
|
|
19
|
+
(pullRequest.files ?? []).some((path) => path.startsWith(`${planDirectory}/`))
|
|
20
|
+
|
|
21
|
+
/** The open pull requests that change a file under `planDirectory`, by number, so several heads merge in a fixed order. */
|
|
22
|
+
export const planPullRequests = <Open extends PlanPullRequest>(
|
|
23
|
+
pullRequests: readonly Open[],
|
|
24
|
+
planDirectory: string,
|
|
25
|
+
): Open[] =>
|
|
26
|
+
pullRequests
|
|
27
|
+
.filter((pullRequest) => changesPlan(pullRequest, planDirectory))
|
|
28
|
+
.sort((first, second) => first.number - second.number)
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `readerOf` asked once per ref, the reader kept for every later ask: build it once per read of all lanes, so a
|
|
32
|
+
* head costs one `git rev-parse` however many lanes read it.
|
|
33
|
+
*/
|
|
34
|
+
export const readerOncePerRef = (readerOf: (ref: string) => BaseReader): ((ref: string) => BaseReader) => {
|
|
35
|
+
const readers = new Map<string, BaseReader>()
|
|
36
|
+
return (ref) => {
|
|
37
|
+
const kept = readers.get(ref) ?? readerOf(ref)
|
|
38
|
+
readers.set(ref, kept)
|
|
39
|
+
return kept
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The heads a lane's plans are read from besides the base: every plan pull request but the one of the lane's own
|
|
45
|
+
* branch, whose ticks the lane's worktree holds already.
|
|
46
|
+
*/
|
|
47
|
+
export const laneHeads = (
|
|
48
|
+
pullRequests: readonly PlanPullRequest[],
|
|
49
|
+
ownBranch: string | null,
|
|
50
|
+
readerOf: (ref: string) => BaseReader,
|
|
51
|
+
): PlanHead[] =>
|
|
52
|
+
pullRequests
|
|
53
|
+
.filter(({ branch }) => branch !== ownBranch)
|
|
54
|
+
.map(({ number, files }) => ({ number, files: files ?? [], read: readerOf(pullHeadRef(number)) }))
|
|
@@ -14,6 +14,8 @@ export type PullRequest = {
|
|
|
14
14
|
checks: ChecksState
|
|
15
15
|
/** The names of the checks that failed, each once, in the rollup's order; empty while none failed. */
|
|
16
16
|
failedChecks: string[]
|
|
17
|
+
/** The paths the pull request changes, as `gh` lists them (at most 100); absent when the answer has none. */
|
|
18
|
+
files?: string[]
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -40,10 +42,11 @@ export type GhPullRequest = {
|
|
|
40
42
|
headRefName: string
|
|
41
43
|
isDraft: boolean
|
|
42
44
|
statusCheckRollup: RollupEntry[] | null
|
|
45
|
+
files?: { path: string }[]
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/** The fields `parsePullRequests` reads; pass them to `gh pr list --json`. */
|
|
46
|
-
export const PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,statusCheckRollup'
|
|
49
|
+
export const PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,statusCheckRollup,files'
|
|
47
50
|
|
|
48
51
|
const RUN_IN_PATH = /^\/([\w.-]+)\/([\w.-]+)\/actions\/runs\/(\d+)(?:\/|$)/
|
|
49
52
|
|
|
@@ -131,6 +134,16 @@ const isPullRequestPage = (url: string, number: number, host: string): boolean =
|
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
const isFileList = (value: unknown): boolean =>
|
|
138
|
+
Array.isArray(value) &&
|
|
139
|
+
value.every(
|
|
140
|
+
(file) => typeof file === 'object' && file !== null && typeof (file as { path?: unknown }).path === 'string',
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
/** The `files` of a `gh` answer as plain paths, to spread into a pull request; nothing when the answer has none. */
|
|
144
|
+
export const filePathsOf = ({ files }: Pick<GhPullRequest, 'files'>): { files?: string[] } =>
|
|
145
|
+
files ? { files: files.map(({ path }) => path) } : {}
|
|
146
|
+
|
|
134
147
|
/** One entry of `gh pr list --json <PULL_REQUEST_FIELDS>` whose link is the pull request's own page on `host`. */
|
|
135
148
|
export const isGhPullRequest = (value: unknown, host: string): value is GhPullRequest => {
|
|
136
149
|
if (typeof value !== 'object' || value === null) return false
|
|
@@ -142,7 +155,8 @@ export const isGhPullRequest = (value: unknown, host: string): value is GhPullRe
|
|
|
142
155
|
isPullRequestPage(candidate.url, candidate.number, host) &&
|
|
143
156
|
typeof candidate.headRefName === 'string' &&
|
|
144
157
|
typeof candidate.isDraft === 'boolean' &&
|
|
145
|
-
(candidate.statusCheckRollup === null || Array.isArray(candidate.statusCheckRollup))
|
|
158
|
+
(candidate.statusCheckRollup === null || Array.isArray(candidate.statusCheckRollup)) &&
|
|
159
|
+
(candidate.files === undefined || isFileList(candidate.files))
|
|
146
160
|
)
|
|
147
161
|
}
|
|
148
162
|
|
|
@@ -163,6 +177,7 @@ export const parsePullRequests = (json: string, host = 'github.com'): PullReques
|
|
|
163
177
|
draft: pullRequest.isDraft,
|
|
164
178
|
checks: checksOf(pullRequest.statusCheckRollup ?? []),
|
|
165
179
|
failedChecks: failedChecksOf(pullRequest.statusCheckRollup ?? [], pullRequest.url).map(({ name }) => name),
|
|
180
|
+
...filePathsOf(pullRequest),
|
|
166
181
|
}))
|
|
167
182
|
}
|
|
168
183
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { clockText } from '../plan-format/clock'
|
|
2
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
+
import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
|
|
4
|
+
import { isRecordId } from './record-id'
|
|
5
|
+
import { isStillWaiting } from './waiting-window'
|
|
6
|
+
|
|
7
|
+
/** The record of a question that ended without an answer, as the kit hook `open-question` writes it to `.jackherd/questions/`. */
|
|
8
|
+
export type QuestionRecord = {
|
|
9
|
+
id: string
|
|
10
|
+
question: string
|
|
11
|
+
askedAt: number
|
|
12
|
+
cwd: string
|
|
13
|
+
session: string
|
|
14
|
+
closedAt: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whether `value` has the shape of a question record's id: twelve lower-case hex digits. */
|
|
18
|
+
export const isQuestionId = isRecordId
|
|
19
|
+
|
|
20
|
+
/** The same shape as `isQuestionRecord` in the hook, which this package does not import at run time (contract test). */
|
|
21
|
+
export const isQuestionRecord = (value: unknown): value is QuestionRecord => {
|
|
22
|
+
if (typeof value !== 'object' || value === null) return false
|
|
23
|
+
const record = value as Record<string, unknown>
|
|
24
|
+
return (
|
|
25
|
+
typeof record.id === 'string' &&
|
|
26
|
+
isQuestionId(record.id) &&
|
|
27
|
+
typeof record.question === 'string' &&
|
|
28
|
+
typeof record.askedAt === 'number' &&
|
|
29
|
+
typeof record.cwd === 'string' &&
|
|
30
|
+
typeof record.session === 'string' &&
|
|
31
|
+
(record.closedAt === null || typeof record.closedAt === 'number')
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const itemOf = (record: QuestionRecord): WaitingItem => ({
|
|
36
|
+
since: clockText(record.askedAt),
|
|
37
|
+
item: terminalSafe(record.question),
|
|
38
|
+
kind: WAITING_KIND.question,
|
|
39
|
+
id: record.id,
|
|
40
|
+
open: true,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/** What the unanswered questions wait on: one open item per record younger than three days, oldest first. */
|
|
44
|
+
export const questionWaitingItems = (records: readonly QuestionRecord[], now: number): WaitingItem[] =>
|
|
45
|
+
records
|
|
46
|
+
.filter(({ closedAt, askedAt }) => isStillWaiting(closedAt, askedAt, now))
|
|
47
|
+
.sort((left, right) => left.askedAt - right.askedAt)
|
|
48
|
+
.map(itemOf)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { closableRecordLog } from './closable-records'
|
|
2
|
+
import { isQuestionRecord } from './question-records'
|
|
3
|
+
|
|
4
|
+
const log = closableRecordLog('questions', isQuestionRecord, ({ askedAt }) => askedAt)
|
|
5
|
+
|
|
6
|
+
/** Every record of the question log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
|
|
7
|
+
export const readQuestionRecords = log.read
|
|
8
|
+
|
|
9
|
+
/** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
|
|
10
|
+
export const closeQuestionRecords = log.close
|
|
11
|
+
|
|
12
|
+
/** Marks every open record `closedAt` and returns how many it closed. */
|
|
13
|
+
export const closeAllQuestionRecords = log.closeAll
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { StatCache } from '../shared/stat-cache'
|
|
2
|
+
import type { TaskSignal } from '../task-signals/types'
|
|
3
|
+
import type { PlanCache } from './plan-cache'
|
|
4
|
+
|
|
5
|
+
/** What the herd keeps between its reads, so session logs and plans that did not change are not parsed again. */
|
|
6
|
+
export type ReadCache = { signals: StatCache<TaskSignal[]>; plans: PlanCache }
|
|
7
|
+
|
|
8
|
+
export const newReadCache = (): ReadCache => ({ signals: new Map(), plans: new Map() })
|
|
@@ -2,6 +2,12 @@ import { constants } from 'node:os'
|
|
|
2
2
|
|
|
3
3
|
export const CHILD_MARKER = 'JACKHERD_PLANS_CHILD'
|
|
4
4
|
|
|
5
|
+
/** The signal a newer herd view sends the older one of its terminal, so the older one ends as a wanted end. */
|
|
6
|
+
export const TAKEOVER_SIGNAL = 'SIGUSR2'
|
|
7
|
+
|
|
8
|
+
/** The exit code of a view that ended because a newer herd took its terminal over. */
|
|
9
|
+
export const TAKEN_OVER_CODE = 75
|
|
10
|
+
|
|
5
11
|
/** Milliseconds a burst of file changes is collapsed into one restart. */
|
|
6
12
|
export const RESTART_DEBOUNCE_MS = 300
|
|
7
13
|
|
|
@@ -26,12 +32,15 @@ export type NextStep = 'respawn' | 'wait' | 'exit'
|
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
34
|
* What the supervisor does once the child has ended. Only a wanted end stops it: a forwarded
|
|
29
|
-
* signal (`stopping`)
|
|
30
|
-
*
|
|
35
|
+
* signal (`stopping`), a takeover by a newer herd (the exit code `TAKEN_OVER_CODE`, or the end by SIGKILL or
|
|
36
|
+
* `TAKEOVER_SIGNAL` that the newer herd causes when the child does not answer in time) or a clean exit code 0 (`q`,
|
|
37
|
+
* Ctrl-C in raw mode). A restart we asked for respawns, even when our own stopper had to SIGKILL the child; any other
|
|
38
|
+
* end is a crash, and the supervisor waits for the next change to the source.
|
|
31
39
|
*/
|
|
32
|
-
export const nextStep = ({ restarting, stopping, code }: ChildExit): NextStep => {
|
|
33
|
-
if (stopping) return 'exit'
|
|
40
|
+
export const nextStep = ({ restarting, stopping, code, signal }: ChildExit): NextStep => {
|
|
41
|
+
if (stopping || code === TAKEN_OVER_CODE) return 'exit'
|
|
34
42
|
if (restarting) return 'respawn'
|
|
43
|
+
if (signal === 'SIGKILL' || signal === TAKEOVER_SIGNAL) return 'exit'
|
|
35
44
|
return code === 0 ? 'exit' : 'wait'
|
|
36
45
|
}
|
|
37
46
|
|