jackherd 1.8.1 → 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 +60 -0
- package/INSTALL.md +3 -1
- package/README.md +75 -64
- package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
- package/dist/config.d.ts +45 -4
- package/dist/config.js +84 -27
- package/dist/plan-format.d.ts +63 -7
- package/dist/plan-format.js +66 -12
- 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/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -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 -140
- 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/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +9 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- 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 +21 -4
- package/kit/hooks/statusline.sh +1 -1
- 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 +10 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
- 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 +4 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +85 -7
- 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 +4 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +80 -26
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- 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 +12 -0
- package/src/plan-format/dependencies.ts +9 -9
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +53 -0
- package/src/plans/active-tasks.ts +60 -0
- 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/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- 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-standing.ts +9 -4
- package/src/plans/lane-summary.ts +367 -92
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +80 -19
- package/src/plans/layout-summary.ts +163 -74
- package/src/plans/layout.ts +472 -114
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +119 -10
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -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 +23 -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 +117 -34
- package/src/plans/sent-files-read.ts +13 -0
- package/src/plans/sent-files.ts +114 -0
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +62 -0
- package/src/plans/snapshot.ts +279 -60
- 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 +131 -0
- package/src/plans/subagents.ts +190 -0
- 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 +36 -14
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +20 -12
- package/src/plans/waiting-click.ts +231 -0
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +105 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-location.ts +89 -0
- package/src/shared/private-records.ts +48 -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/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +89 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +16 -4
- package/src/terminal/width.ts +2 -1
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +44 -6
- package/src/waiting/command.ts +50 -0
- package/src/watch/appointments.ts +2 -4
- package/src/watch/approvals.ts +2 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +401 -90
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +58 -14
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +37 -5
- package/src/watch/lane-rest.ts +28 -0
- 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/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +8 -3
- 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/source-path.ts +1 -1
- package/src/watch/sources.ts +7 -4
- package/src/watch/stream-output.ts +14 -8
- 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 +108 -0
- package/src/watch/task-parallel-read.ts +151 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +235 -0
- 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/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +36 -1
- package/src/watch/watcher-lock.ts +25 -29
- package/src/watch/workflow-runs.ts +2 -1
- 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
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { fetchBase } from '../plans/fetch'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { fetchBase, fetchPlanHeads } from '../plans/fetch'
|
|
3
|
+
import { commitOf } from '../plans/git'
|
|
4
|
+
import { BASE_REMOTE, baseRefOf } from '../plans/settings'
|
|
5
|
+
import { baseFetchNeeded, movedHeads, remoteTipOf } from './base-tip'
|
|
6
|
+
import { behindCountOf, type MainCheckoutRead } from './main-checkout'
|
|
7
|
+
import type { WatchedPullRequest } from './pull-requests'
|
|
5
8
|
|
|
6
9
|
const GIT_ASYNC_TIMEOUT_MS = 30_000
|
|
7
10
|
|
|
@@ -31,6 +34,20 @@ const git = (cwd: string, args: string[]): string | null => {
|
|
|
31
34
|
export const branchOf = (worktree: string): string | null =>
|
|
32
35
|
existsSync(worktree) ? git(worktree, ['branch', '--show-current']) || null : null
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The main checkout's branch and the commits of `origin/<base>` it lacks, as far as git can tell; read from local refs
|
|
39
|
+
* only. Null when git cannot resolve HEAD, so an unreadable checkout never reads as a detached head.
|
|
40
|
+
*/
|
|
41
|
+
export const readMainCheckout = (root: string, baseBranch: string): MainCheckoutRead | null => {
|
|
42
|
+
if (git(root, ['rev-parse', '--verify', '--quiet', 'HEAD']) === null) return null
|
|
43
|
+
const remoteBase = baseRefOf({ baseBranch })
|
|
44
|
+
return {
|
|
45
|
+
branch: branchOf(root),
|
|
46
|
+
behind: behindCountOf(git(root, ['rev-list', '--count', `HEAD..${remoteBase}`])),
|
|
47
|
+
remoteBase,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
/**
|
|
35
52
|
* Fetches the base branch when the remote moved past `origin/<branch>`, so every reader of that ref
|
|
36
53
|
* (the herd watches `FETCH_HEAD`) sees a merge as soon as the watcher does. Asks the remote
|
|
@@ -44,3 +61,14 @@ export const fetchBaseWhenMoved = async (root: string, branch: string): Promise<
|
|
|
44
61
|
if (!baseFetchNeeded(remote === null ? null : remoteTipOf(remote, branch), local?.trim() || null)) return
|
|
45
62
|
await fetchBase(root, BASE_REMOTE, branch)
|
|
46
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fetches the heads of the plan pull requests whose head commit moved past the herd's ref of it, in one call, so the
|
|
67
|
+
* watcher and the herd read the same ticks; an unchanged head costs no fetch, a failure leaves the refs as they are.
|
|
68
|
+
*/
|
|
69
|
+
export const fetchPlanHeadsWhenMoved = (root: string, pullRequests: readonly WatchedPullRequest[]): Promise<void> =>
|
|
70
|
+
fetchPlanHeads(
|
|
71
|
+
root,
|
|
72
|
+
BASE_REMOTE,
|
|
73
|
+
movedHeads(pullRequests, (ref) => commitOf(root, ref)),
|
|
74
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import type { WatchTexts } from './texts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What git says about the main checkout: the branch it has checked out (null for a detached head) and the commits of
|
|
6
|
+
* `remoteBase` (`origin/<base>`) it lacks.
|
|
7
|
+
*/
|
|
8
|
+
export type MainCheckoutRead = { branch: string | null; behind: number | null; remoteBase: string }
|
|
9
|
+
|
|
10
|
+
const COUNT = /^\d+$/
|
|
11
|
+
|
|
12
|
+
/** The count `git rev-list --count` printed; null when git failed or printed something else. */
|
|
13
|
+
export const behindCountOf = (revList: string | null): number | null => {
|
|
14
|
+
const count = revList?.trim() ?? ''
|
|
15
|
+
return COUNT.test(count) ? Number(count) : null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The duty line of the main checkout, in every report and heartbeat until it is resolved. With another branch or a
|
|
20
|
+
* detached head checked out: `main checkout on <branch>, not <base>`, since the main checkout is the coordinator's seat
|
|
21
|
+
* on the base and a fast-forward would land on the wrong branch. With the base checked out and behind the merges on
|
|
22
|
+
* the remote: `main checkout behind origin/<base> by <n> (git merge --ff-only origin/<base>)`. Null when caught up or
|
|
23
|
+
* unreadable; a `read` of null (git could not resolve the main checkout's HEAD) is unreadable, not a detached head.
|
|
24
|
+
*/
|
|
25
|
+
export const mainCheckoutDutyLine = (
|
|
26
|
+
read: MainCheckoutRead | null,
|
|
27
|
+
baseBranch: string,
|
|
28
|
+
texts: WatchTexts,
|
|
29
|
+
): string | null => {
|
|
30
|
+
if (read === null) return null
|
|
31
|
+
if (read.branch !== baseBranch) return terminalSafe(texts.mainCheckoutOffBase(read.branch, baseBranch))
|
|
32
|
+
if (read.behind === null || read.behind === 0) return null
|
|
33
|
+
return terminalSafe(texts.mainCheckoutBehind(read.remoteBase, read.behind))
|
|
34
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { dirname, join } from 'node:path'
|
|
2
|
+
import { isPrivateLevels, type LocationSystem, readPrivateText, systemLocation } from '../shared/private-location'
|
|
3
3
|
import {
|
|
4
4
|
sourceTicketsOf,
|
|
5
5
|
type TicketSource,
|
|
@@ -12,8 +12,10 @@ import { parseWatchState, type WatchState } from './watch-events'
|
|
|
12
12
|
import { type CommandLineOf, isWatcherRunning, WATCHER_FILES, watcherDirectoryOf } from './watcher-lock'
|
|
13
13
|
|
|
14
14
|
const readState = (file: string): WatchState | null => {
|
|
15
|
+
const text = readPrivateText(file)
|
|
16
|
+
if (text === null) return null
|
|
15
17
|
try {
|
|
16
|
-
return parseWatchState(
|
|
18
|
+
return parseWatchState(text)
|
|
17
19
|
} catch {
|
|
18
20
|
return null
|
|
19
21
|
}
|
|
@@ -22,19 +24,23 @@ const readState = (file: string): WatchState | null => {
|
|
|
22
24
|
/**
|
|
23
25
|
* What the watcher of the repository at `root` gives the herd, read from its state file without a GitHub call:
|
|
24
26
|
* its badges, the tickets of `ticketSources`, and its health, so the view can say why there are none. A missing or
|
|
25
|
-
* damaged file shows no badges; the view goes on without them.
|
|
27
|
+
* damaged file shows no badges; the view goes on without them. Nothing is read unless both levels of the watcher
|
|
28
|
+
* directory are private (`isPrivateLevels`), and only regular files, so a directory, a file or a FIFO someone else
|
|
29
|
+
* planted neither feeds the view nor blocks it.
|
|
26
30
|
*/
|
|
27
31
|
export const readWatchReading = (
|
|
28
32
|
root: string,
|
|
29
33
|
now: number,
|
|
30
34
|
ticketSources: readonly TicketSource[],
|
|
31
35
|
lookUp?: CommandLineOf,
|
|
36
|
+
system: LocationSystem = systemLocation(),
|
|
32
37
|
): WatchReading => {
|
|
33
|
-
const directory = watcherDirectoryOf(root)
|
|
34
|
-
const
|
|
38
|
+
const directory = watcherDirectoryOf(root, system)
|
|
39
|
+
const isPrivate = isPrivateLevels(dirname(directory), directory, system)
|
|
40
|
+
const state = isPrivate ? readState(join(directory, WATCHER_FILES.state)) : null
|
|
35
41
|
const health = watchHealthOf(
|
|
36
42
|
state?.savedAt ?? null,
|
|
37
|
-
isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
|
|
43
|
+
isPrivate && isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
|
|
38
44
|
now,
|
|
39
45
|
)
|
|
40
46
|
if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health }
|
package/src/watch/plan-badges.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CHECKS } from '../plans/pull-requests'
|
|
2
|
-
import { sanitisedFailedTests } from './failed-tests'
|
|
3
|
-
import { failedTestsOf } from './failures'
|
|
2
|
+
import { sanitisedFailedTestPlaces, sanitisedFailedTests } from './failed-tests'
|
|
3
|
+
import { failedTestPlacesOf, failedTestsOf } from './failures'
|
|
4
4
|
import { previewUrlOf } from './previews'
|
|
5
5
|
import { PULL_REQUEST_STATE } from './pull-requests'
|
|
6
6
|
import { lineText, type TicketRecord } from './sources'
|
|
@@ -16,6 +16,8 @@ export type PullRequestBadges = {
|
|
|
16
16
|
changesRequested: boolean
|
|
17
17
|
/** The tests a red run failed on this head commit, as the watcher read them from the run's log; absent reads as none. */
|
|
18
18
|
failedTests?: string[]
|
|
19
|
+
/** By failed test, the package or test file the log printed before it; a test without an entry has no known place. */
|
|
20
|
+
failedTestPlaces?: Record<string, string>
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/** The badges of a pull request the watcher shows nothing for. */
|
|
@@ -82,11 +84,16 @@ export const watchBadgesOf = (state: WatchState, now: number): WatchBadges => {
|
|
|
82
84
|
const preview = state.previews.pullRequests[number]
|
|
83
85
|
const feedback = state.feedback.pullRequests[number]
|
|
84
86
|
const failedTests = checks === CHECKS.failed ? shownFailedTestsOf(state, number, headCommit) : []
|
|
87
|
+
const places = sanitisedFailedTestPlaces(
|
|
88
|
+
failedTestPlacesOf(state.failures, Number(number), headCommit),
|
|
89
|
+
failedTests,
|
|
90
|
+
)
|
|
85
91
|
const badges = {
|
|
86
92
|
preview: preview?.reportedHead === headCommit ? (preview.url ?? null) : null,
|
|
87
93
|
feedback: feedback?.headCommit === headCommit ? feedback.count : 0,
|
|
88
94
|
changesRequested: feedback?.changesRequested === true,
|
|
89
95
|
...(failedTests.length > 0 && { failedTests }),
|
|
96
|
+
...(Object.keys(places).length > 0 && { failedTestPlaces: places }),
|
|
90
97
|
}
|
|
91
98
|
const shown = badges.preview !== null || badges.feedback > 0 || badges.changesRequested || failedTests.length > 0
|
|
92
99
|
return shown ? [[number, badges]] : []
|
package/src/watch/previews.ts
CHANGED
|
@@ -4,10 +4,12 @@ import {
|
|
|
4
4
|
checksOf,
|
|
5
5
|
type FailedCheck,
|
|
6
6
|
failedChecksOf,
|
|
7
|
+
filePathsOf,
|
|
7
8
|
type GhPullRequest,
|
|
8
9
|
isGhPullRequest,
|
|
9
10
|
} from '../plans/pull-requests'
|
|
10
|
-
import { isRecord
|
|
11
|
+
import { isRecord } from '../shared/is-record'
|
|
12
|
+
import { parseArray } from './json-guards'
|
|
11
13
|
|
|
12
14
|
/** The states of a pull request in `gh pr list --json state`. */
|
|
13
15
|
export const PULL_REQUEST_STATE = { open: 'OPEN', merged: 'MERGED', closed: 'CLOSED' } as const
|
|
@@ -24,6 +26,8 @@ export type WatchedPullRequest = {
|
|
|
24
26
|
failedRuns: FailedCheck[]
|
|
25
27
|
headCommit: string
|
|
26
28
|
mergeCommit: string | null
|
|
29
|
+
/** The paths it changes (at most 100), from the open list only; a plan among them makes its head a plan head. */
|
|
30
|
+
files?: string[]
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** The rollup is optional: the light list has none. */
|
|
@@ -40,8 +44,8 @@ type GhWatchedPullRequest = Omit<GhPullRequest, 'statusCheckRollup'> & {
|
|
|
40
44
|
*/
|
|
41
45
|
export const WATCHED_PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,state,headRefOid,mergeCommit'
|
|
42
46
|
|
|
43
|
-
/** The fields of the list of open pull requests, which is the only one that asks for their checks. */
|
|
44
|
-
export const OPEN_PULL_REQUEST_FIELDS = `${WATCHED_PULL_REQUEST_FIELDS},statusCheckRollup`
|
|
47
|
+
/** The fields of the list of open pull requests, which is the only one that asks for their checks and their files. */
|
|
48
|
+
export const OPEN_PULL_REQUEST_FIELDS = `${WATCHED_PULL_REQUEST_FIELDS},statusCheckRollup,files`
|
|
45
49
|
|
|
46
50
|
const GH_PR_LIST = 'gh pr list'
|
|
47
51
|
|
|
@@ -78,6 +82,7 @@ const watchedPullRequestsOf = (
|
|
|
78
82
|
failedRuns: failedChecksOf(pullRequest.statusCheckRollup ?? [], pullRequest.url),
|
|
79
83
|
headCommit: pullRequest.headRefOid,
|
|
80
84
|
mergeCommit: pullRequest.mergeCommit?.oid ?? null,
|
|
85
|
+
...filePathsOf(pullRequest),
|
|
81
86
|
}))
|
|
82
87
|
|
|
83
88
|
const asIs = (entry: unknown): unknown => entry
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { claudePath, SKILLS_DIRECTORY } from '../claude/paths'
|
|
3
|
+
import { CONTEXT_LEVEL } from '../context'
|
|
4
|
+
import { APPOINTMENT_EVENT } from './appointments'
|
|
5
|
+
import { UPDATE_AVAILABLE_EVENT, WATCH_EVENT } from './watch-events'
|
|
6
|
+
|
|
7
|
+
/** The skill the watcher points the coordinator to, by its name without a prefix. */
|
|
8
|
+
export const ORCHESTRATE_SKILL_NAME = 'orchestrate'
|
|
9
|
+
|
|
10
|
+
/** The reference files of the orchestrate skill, relative to its directory. */
|
|
11
|
+
export const REFERENCE = {
|
|
12
|
+
events: 'references/events.md',
|
|
13
|
+
laneSession: 'references/lane-session.md',
|
|
14
|
+
coordinatorHandover: 'references/coordinator-handover.md',
|
|
15
|
+
closeLane: 'references/close-lane.md',
|
|
16
|
+
laneHandover: 'references/lane-handover.md',
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
export type Reference = (typeof REFERENCE)[keyof typeof REFERENCE]
|
|
20
|
+
|
|
21
|
+
/** A copy of the orchestrate skill: the directory as the session reads it, and where that is on disk. */
|
|
22
|
+
export type SkillCopy = { shown: string; path: string }
|
|
23
|
+
|
|
24
|
+
const SKILL_ENTRY = 'SKILL.md'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The rare events and the reference that holds their handling. The frequent ones (`blocked`, `done`/`idle`, `merged`,
|
|
28
|
+
* the checks, `heartbeat`, `approval`, the lanes' fill, the watcher's end, `kit updated`) stay in the skill and have
|
|
29
|
+
* no path, so their lines do not grow.
|
|
30
|
+
*/
|
|
31
|
+
const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
|
|
32
|
+
[WATCH_EVENT.feedback]: REFERENCE.events,
|
|
33
|
+
[WATCH_EVENT.preview]: REFERENCE.events,
|
|
34
|
+
[WATCH_EVENT.source]: REFERENCE.events,
|
|
35
|
+
[APPOINTMENT_EVENT.unreadable]: REFERENCE.events,
|
|
36
|
+
[WATCH_EVENT.laneParallel]: REFERENCE.events,
|
|
37
|
+
[WATCH_EVENT.taskParallel]: REFERENCE.events,
|
|
38
|
+
[WATCH_EVENT.noTaskSignal]: REFERENCE.events,
|
|
39
|
+
[UPDATE_AVAILABLE_EVENT]: REFERENCE.events,
|
|
40
|
+
[WATCH_EVENT.laneModel]: REFERENCE.laneSession,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const isCoordinatorHandoverLevel = (level: unknown): boolean =>
|
|
44
|
+
level === CONTEXT_LEVEL.warn || level === CONTEXT_LEVEL.alert
|
|
45
|
+
|
|
46
|
+
/** The reference to read for `event`; null for a frequent event, and for a coordinator still at `ok`. */
|
|
47
|
+
export const eventReference = (event: { kind: string; level?: unknown }): Reference | null => {
|
|
48
|
+
if (event.kind === WATCH_EVENT.coordinator)
|
|
49
|
+
return isCoordinatorHandoverLevel(event.level) ? REFERENCE.coordinatorHandover : null
|
|
50
|
+
return EVENT_REFERENCES[event.kind] ?? null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The places the orchestrate skill is installed, the project's first: `<root>/.claude/skills/<prefix>orchestrate`, and
|
|
55
|
+
* the global copy `~/.claude/skills/<globalPrefix>orchestrate` that `jh sync --global` writes (`toGlobalKit`). The
|
|
56
|
+
* project shows its path relative to the root the session runs in, the global one with `~`.
|
|
57
|
+
*/
|
|
58
|
+
export const skillCopiesFor = (
|
|
59
|
+
root: string,
|
|
60
|
+
home: string,
|
|
61
|
+
projectPrefix: string,
|
|
62
|
+
globalPrefix: string,
|
|
63
|
+
): SkillCopy[] => {
|
|
64
|
+
const project = claudePath(SKILLS_DIRECTORY, `${projectPrefix}${ORCHESTRATE_SKILL_NAME}`)
|
|
65
|
+
const global = claudePath(SKILLS_DIRECTORY, `${globalPrefix}${ORCHESTRATE_SKILL_NAME}`)
|
|
66
|
+
return [
|
|
67
|
+
{ shown: project, path: join(root, project) },
|
|
68
|
+
{ shown: `~/${global}`, path: join(home, global) },
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The directory of the first copy that holds the skill: the project's installation, else the global one. `exists`
|
|
74
|
+
* answers for a path on disk, so the choice stays a pure function.
|
|
75
|
+
*/
|
|
76
|
+
export const skillDirectoryFor = (copies: readonly SkillCopy[], exists: (path: string) => boolean): string | null =>
|
|
77
|
+
copies.find(({ path }) => exists(join(path, SKILL_ENTRY)))?.shown ?? null
|
|
78
|
+
|
|
79
|
+
/** ` · read <skill directory>/<reference>`, naming the skill when no copy of it was found. */
|
|
80
|
+
export const referenceHint = (reference: Reference, skillDirectory: string | null): string =>
|
|
81
|
+
skillDirectory === null
|
|
82
|
+
? ` · read ${ORCHESTRATE_SKILL_NAME} skill ${reference}`
|
|
83
|
+
: ` · read ${skillDirectory}/${reference}`
|
|
84
|
+
|
|
85
|
+
/** `lines` with `hint` on the first one, the header of a multi-line event. */
|
|
86
|
+
export const annotateFirstLine = (lines: readonly string[], hint: string): string[] =>
|
|
87
|
+
lines.map((line, index) => (index === 0 ? `${line}${hint}` : line))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RunningSubagent } from '../plans/subagents'
|
|
2
|
+
import { laneDirectoryOf } from '../plans/unlisted-lanes'
|
|
3
|
+
import { type LaneNotes, type PhaseCount, progressNotes } from './heartbeat-progress'
|
|
4
|
+
|
|
5
|
+
/** The running subagents of each worktree below `worktreeDirectory`, by lane name; one that works elsewhere is left out. */
|
|
6
|
+
export const runningSubagentCountsOf = (
|
|
7
|
+
running: readonly Pick<RunningSubagent, 'cwd'>[],
|
|
8
|
+
worktreeDirectory: string,
|
|
9
|
+
): Record<string, number> => {
|
|
10
|
+
const counts: Record<string, number> = {}
|
|
11
|
+
for (const { cwd } of running) {
|
|
12
|
+
const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
|
|
13
|
+
if (lane !== null) counts[lane] = (counts[lane] ?? 0) + 1
|
|
14
|
+
}
|
|
15
|
+
return counts
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What a heartbeat says about running subagents: `3 subagents`; null for none. */
|
|
19
|
+
export const subagentsNote = (count: number): string | null =>
|
|
20
|
+
count <= 0 ? null : `${count} ${count === 1 ? 'subagent' : 'subagents'}`
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The heartbeat notes with the running subagents of each working lane appended (`P12 7/12, 3 subagents`); a lane that
|
|
24
|
+
* has no count note yet gets the subagents alone. Resting lanes and lanes without running subagents keep their note.
|
|
25
|
+
*/
|
|
26
|
+
export const withSubagentNotes = (
|
|
27
|
+
notes: LaneNotes,
|
|
28
|
+
working: readonly string[],
|
|
29
|
+
counts: Readonly<Record<string, number>>,
|
|
30
|
+
): LaneNotes => {
|
|
31
|
+
const extra = working.flatMap((lane) => {
|
|
32
|
+
const note = subagentsNote(counts[lane] ?? 0)
|
|
33
|
+
return note === null ? [] : [[lane, [notes[lane], note].filter(Boolean).join(', ')]]
|
|
34
|
+
})
|
|
35
|
+
return { ...notes, ...Object.fromEntries(extra) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The notes of a heartbeat: each working lane's phase count and its running subagents; resting lanes have neither. */
|
|
39
|
+
export const heartbeatNotesOf = (
|
|
40
|
+
working: readonly string[],
|
|
41
|
+
phaseCounts: Readonly<Record<string, PhaseCount>>,
|
|
42
|
+
subagentCounts: Readonly<Record<string, number>>,
|
|
43
|
+
): LaneNotes => withSubagentNotes(progressNotes(working, phaseCounts), working, subagentCounts)
|
|
@@ -1,13 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
3
4
|
import { errorText } from '../plans/error-text'
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
changedSections,
|
|
7
|
+
orchestrateChangeLines,
|
|
8
|
+
REFERENCE_CHANGE,
|
|
9
|
+
type ReferenceChange,
|
|
10
|
+
type ReferenceDelta,
|
|
11
|
+
referenceChanges,
|
|
12
|
+
SKILL_CHANGE,
|
|
13
|
+
type SkillChange,
|
|
14
|
+
} from '../version/skill-diff'
|
|
5
15
|
import type { StartedKit } from '../version/started-kit'
|
|
6
|
-
import {
|
|
16
|
+
import { ORCHESTRATE_SKILL_NAME } from './reference-paths'
|
|
17
|
+
|
|
18
|
+
/** The orchestrate skill as the kit ships it: its text and its reference files by name. */
|
|
19
|
+
export type OrchestrateSnapshot = { skill: string; references: Record<string, string> }
|
|
20
|
+
|
|
21
|
+
/** The change since a snapshot: the skill itself, and each reference that changed, is new or is gone. */
|
|
22
|
+
export type OrchestrateChange = { skill: SkillChange; references: ReferenceChange[] }
|
|
23
|
+
|
|
24
|
+
type WrittenDiff = { text: string; path: string }
|
|
25
|
+
|
|
26
|
+
/** A reference name that stays a plain Markdown file name, since it goes into the name of a diff file. */
|
|
27
|
+
const REFERENCE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/
|
|
28
|
+
|
|
29
|
+
const skillDirectory = (packageRoot: string): string =>
|
|
30
|
+
join(packageRoot, 'kit', 'workflow', 'skills', ORCHESTRATE_SKILL_NAME)
|
|
7
31
|
|
|
8
32
|
/** The kit's own text of the orchestrate skill, with its placeholders, inside the package. */
|
|
9
|
-
export const orchestrateSkillPath = (packageRoot: string): string =>
|
|
10
|
-
|
|
33
|
+
export const orchestrateSkillPath = (packageRoot: string): string => join(skillDirectory(packageRoot), 'SKILL.md')
|
|
34
|
+
|
|
35
|
+
/** The directory of the skill's reference files in the kit at `packageRoot`. */
|
|
36
|
+
export const orchestrateReferencesPath = (packageRoot: string): string =>
|
|
37
|
+
join(skillDirectory(packageRoot), 'references')
|
|
11
38
|
|
|
12
39
|
/** The orchestrate skill's text in the kit at `packageRoot`; null when it cannot be read. */
|
|
13
40
|
export const readOrchestrateSkill = (packageRoot: string): string | null => {
|
|
@@ -18,10 +45,45 @@ export const readOrchestrateSkill = (packageRoot: string): string | null => {
|
|
|
18
45
|
}
|
|
19
46
|
}
|
|
20
47
|
|
|
48
|
+
/** The skill's reference files by name (every `*.md` file below `references/`); none when the directory is missing. */
|
|
49
|
+
export const readOrchestrateReferences = (packageRoot: string): Record<string, string> => {
|
|
50
|
+
const directory = orchestrateReferencesPath(packageRoot)
|
|
51
|
+
try {
|
|
52
|
+
const names = readdirSync(directory, { withFileTypes: true })
|
|
53
|
+
.filter((entry) => entry.isFile() && REFERENCE_FILE.test(entry.name))
|
|
54
|
+
.map((entry) => entry.name)
|
|
55
|
+
return Object.fromEntries(names.map((name) => [name, readFileSync(join(directory, name), 'utf8')]))
|
|
56
|
+
} catch {
|
|
57
|
+
return {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The skill and its references in the kit at `packageRoot`; null when the skill cannot be read. */
|
|
62
|
+
export const readOrchestrateSnapshot = (packageRoot: string): OrchestrateSnapshot | null => {
|
|
63
|
+
const skill = readOrchestrateSkill(packageRoot)
|
|
64
|
+
return skill === null ? null : { skill, references: readOrchestrateReferences(packageRoot) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The unified diff (`git diff --no-index`) between `before` and the file at `currentPath`, written to `directory` as
|
|
69
|
+
* `<name>.diff`; null when git has none to give.
|
|
70
|
+
*/
|
|
71
|
+
const writeDiff = (before: string, currentPath: string, directory: string, name: string): WrittenDiff | null => {
|
|
72
|
+
ensurePrivateDirectory(directory)
|
|
73
|
+
const beforePath = join(directory, `${name}.before.md`)
|
|
74
|
+
writePrivateFile(beforePath, before)
|
|
75
|
+
const diff = Bun.spawnSync(['git', 'diff', '--no-index', '--unified=3', '--', beforePath, currentPath])
|
|
76
|
+
const text = diff.stdout.toString()
|
|
77
|
+
if (diff.exitCode !== 1 || text === '') return null
|
|
78
|
+
const path = join(directory, `${name}.diff`)
|
|
79
|
+
writePrivateFile(path, text)
|
|
80
|
+
return { text, path }
|
|
81
|
+
}
|
|
82
|
+
|
|
21
83
|
/**
|
|
22
84
|
* The change of the orchestrate skill between `snapshot` and the kit at `packageRoot` now: the unified diff
|
|
23
|
-
*
|
|
24
|
-
*
|
|
85
|
+
* written to `directory` and the sections it touches, so the coordinator reads the change instead of the whole
|
|
86
|
+
* skill. Unavailable without a snapshot, a readable skill or a diff.
|
|
25
87
|
*/
|
|
26
88
|
export const skillChangeSince = (snapshot: string | null, packageRoot: string, directory: string): SkillChange => {
|
|
27
89
|
if (snapshot === null) return { kind: SKILL_CHANGE.unavailable, reason: 'no snapshot at start' }
|
|
@@ -29,33 +91,63 @@ export const skillChangeSince = (snapshot: string | null, packageRoot: string, d
|
|
|
29
91
|
if (current === null) return { kind: SKILL_CHANGE.unavailable, reason: 'skill not readable' }
|
|
30
92
|
if (current === snapshot) return { kind: SKILL_CHANGE.unchanged }
|
|
31
93
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const diff = Bun.spawnSync([
|
|
36
|
-
'git',
|
|
37
|
-
'diff',
|
|
38
|
-
'--no-index',
|
|
39
|
-
'--unified=3',
|
|
40
|
-
'--',
|
|
41
|
-
before,
|
|
42
|
-
orchestrateSkillPath(packageRoot),
|
|
43
|
-
])
|
|
44
|
-
const text = diff.stdout.toString()
|
|
45
|
-
if (diff.exitCode !== 1 || text === '')
|
|
46
|
-
return { kind: SKILL_CHANGE.unavailable, reason: `git diff exit ${diff.exitCode}` }
|
|
47
|
-
const diffPath = join(directory, 'orchestrate-skill.diff')
|
|
48
|
-
writePrivate(diffPath, text)
|
|
49
|
-
return { kind: SKILL_CHANGE.changed, sections: changedSections(current, text), diffPath }
|
|
94
|
+
const diff = writeDiff(snapshot, orchestrateSkillPath(packageRoot), directory, `${ORCHESTRATE_SKILL_NAME}-skill`)
|
|
95
|
+
if (diff === null) return { kind: SKILL_CHANGE.unavailable, reason: 'git diff gave none' }
|
|
96
|
+
return { kind: SKILL_CHANGE.changed, sections: changedSections(current, diff.text), diffPath: diff.path }
|
|
50
97
|
} catch (error) {
|
|
51
98
|
return { kind: SKILL_CHANGE.unavailable, reason: errorText(error) }
|
|
52
99
|
}
|
|
53
100
|
}
|
|
54
101
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
102
|
+
const referenceChangeOf = (
|
|
103
|
+
delta: ReferenceDelta,
|
|
104
|
+
snapshot: OrchestrateSnapshot,
|
|
105
|
+
packageRoot: string,
|
|
106
|
+
directory: string,
|
|
107
|
+
): ReferenceChange => {
|
|
108
|
+
const path = join(orchestrateReferencesPath(packageRoot), delta.name)
|
|
109
|
+
if (delta.kind === REFERENCE_CHANGE.deleted) return { name: delta.name, kind: REFERENCE_CHANGE.deleted }
|
|
110
|
+
if (delta.kind === REFERENCE_CHANGE.added) return { name: delta.name, kind: REFERENCE_CHANGE.added, path }
|
|
111
|
+
try {
|
|
112
|
+
const diff = writeDiff(
|
|
113
|
+
snapshot.references[delta.name] ?? '',
|
|
114
|
+
path,
|
|
115
|
+
directory,
|
|
116
|
+
`${ORCHESTRATE_SKILL_NAME}-reference-${delta.name}`,
|
|
117
|
+
)
|
|
118
|
+
return { name: delta.name, kind: REFERENCE_CHANGE.changed, diffPath: diff?.path ?? null }
|
|
119
|
+
} catch {
|
|
120
|
+
return { name: delta.name, kind: REFERENCE_CHANGE.changed, diffPath: null }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The change of the skill and of its references since `snapshot`. The references are judged only while the skill
|
|
126
|
+
* can be read, so a package being replaced does not make every reference look deleted.
|
|
127
|
+
*/
|
|
128
|
+
export const orchestrateChangeSince = (
|
|
129
|
+
snapshot: OrchestrateSnapshot | null,
|
|
130
|
+
packageRoot: string,
|
|
131
|
+
directory: string,
|
|
132
|
+
): OrchestrateChange => {
|
|
133
|
+
const skill = skillChangeSince(snapshot?.skill ?? null, packageRoot, directory)
|
|
134
|
+
if (snapshot === null || skill.kind === SKILL_CHANGE.unavailable) return { skill, references: [] }
|
|
135
|
+
const deltas = referenceChanges(snapshot.references, readOrchestrateReferences(packageRoot))
|
|
136
|
+
return { skill, references: deltas.map((delta) => referenceChangeOf(delta, snapshot, packageRoot, directory)) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The lines about the orchestrate skill after a kit update, for the kit the watcher started with: the skill's, then one
|
|
141
|
+
* per changed, new or deleted reference, joined by line breaks.
|
|
142
|
+
*/
|
|
143
|
+
export const skillLineFor = (
|
|
144
|
+
kit: StartedKit | null,
|
|
145
|
+
snapshot: OrchestrateSnapshot | null,
|
|
146
|
+
directory: string,
|
|
147
|
+
): string => {
|
|
148
|
+
if (kit === null) {
|
|
149
|
+
return orchestrateChangeLines({ kind: SKILL_CHANGE.unavailable, reason: 'kit not located' }, []).join('\n')
|
|
150
|
+
}
|
|
151
|
+
const { skill, references } = orchestrateChangeSince(snapshot, kit.packageRoot, directory)
|
|
152
|
+
return orchestrateChangeLines(skill, references).join('\n')
|
|
153
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync, type FSWatcher, watch } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isRestartTrigger, RESTART_DEBOUNCE_MS } from '../plans/restart-decision'
|
|
4
|
+
|
|
5
|
+
/** The changes to a package's `src/` seen so far: when the last one came (null before any), and the watch to close. */
|
|
6
|
+
export type SourceChanges = { lastChangeAt: () => number | null; close: () => void }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether the package at `packageRoot` is a checkout's own source rather than an installed copy, which lies under a
|
|
10
|
+
* `node_modules` directory. Only the source changes under a running process (a pull or a fast-forward of the
|
|
11
|
+
* checkout); an installed copy changes by a reinstall, which its version shows.
|
|
12
|
+
*/
|
|
13
|
+
export const runsFromSource = (packageRoot: string): boolean => !packageRoot.split(/[\\/]/).includes('node_modules')
|
|
14
|
+
|
|
15
|
+
/** Whether a change seen at `lastChangeAt` is over: no further change for the window in which the herd collapses a burst into one restart. */
|
|
16
|
+
export const sourceChangeSettled = (lastChangeAt: number | null, now: number): boolean =>
|
|
17
|
+
lastChangeAt !== null && now - lastChangeAt >= RESTART_DEBOUNCE_MS
|
|
18
|
+
|
|
19
|
+
const NO_CHANGES: SourceChanges = { lastChangeAt: () => null, close: () => {} }
|
|
20
|
+
|
|
21
|
+
/** The watch of `directory`, closed on a later error (`EMFILE` while dev servers run), which would else end the process. */
|
|
22
|
+
const watcherOf = (directory: string, onChange: () => void): FSWatcher | null => {
|
|
23
|
+
try {
|
|
24
|
+
const watcher = watch(directory, { recursive: true }, (_event, name) => {
|
|
25
|
+
if (name && isRestartTrigger(name.toString())) onChange()
|
|
26
|
+
})
|
|
27
|
+
watcher.on('error', () => watcher.close())
|
|
28
|
+
return watcher
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Follows the source files below `<packageRoot>/src` with the trigger the herd restarts on (`isRestartTrigger`: a
|
|
36
|
+
* `.ts` file, tests and declarations left out), stamping each change with `clock`. A package without `src/`, or one
|
|
37
|
+
* the platform cannot watch, is followed for nothing.
|
|
38
|
+
*/
|
|
39
|
+
export const watchSourceChanges = (packageRoot: string, clock: () => number = Date.now): SourceChanges => {
|
|
40
|
+
const directory = join(packageRoot, 'src')
|
|
41
|
+
if (!existsSync(directory)) return NO_CHANGES
|
|
42
|
+
let lastChangeAt: number | null = null
|
|
43
|
+
const watcher = watcherOf(directory, () => {
|
|
44
|
+
lastChangeAt = clock()
|
|
45
|
+
})
|
|
46
|
+
if (!watcher) return NO_CHANGES
|
|
47
|
+
return { lastChangeAt: () => lastChangeAt, close: () => watcher.close() }
|
|
48
|
+
}
|
package/src/watch/source-path.ts
CHANGED
package/src/watch/sources.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
-
import { isRecord } from '
|
|
3
|
+
import { isRecord } from '../shared/is-record'
|
|
4
4
|
import { previewUrlOf } from './previews'
|
|
5
5
|
import { CONTENT_FIELDS, SOURCE_FIELD } from './source-fields'
|
|
6
6
|
import { ROOT_PATH, type SourcePath, selectPath } from './source-path'
|
|
@@ -94,6 +94,8 @@ export type TicketRecord = { url: string | null; status: string | null; changedA
|
|
|
94
94
|
export type SourceRecord = {
|
|
95
95
|
seen: SeenEntry[] | null
|
|
96
96
|
failingSince: number | null
|
|
97
|
+
/** When its last run started, failed or not (`isSourceDue`); absent in a state written before it was kept: never. */
|
|
98
|
+
ranAt?: number
|
|
97
99
|
/** Of a ticket source only: the entries of its last successful run by id. */
|
|
98
100
|
tickets?: Record<string, TicketRecord>
|
|
99
101
|
}
|
|
@@ -248,7 +250,7 @@ export const parseSourceOutput = (
|
|
|
248
250
|
return { ok: true, items: uniqueById(items as SourceItem[]) }
|
|
249
251
|
}
|
|
250
252
|
|
|
251
|
-
/** Whether a source last started at `lastRunAt` (undefined: never
|
|
253
|
+
/** Whether a source last started at `lastRunAt` (`SourceRecord.ranAt`; undefined: never) is due again at `now`. */
|
|
252
254
|
export const isSourceDue = (lastRunAt: number | undefined, everyMinutes: number, now: number): boolean =>
|
|
253
255
|
lastRunAt === undefined || now - lastRunAt >= everyMinutes * MINUTE_MS
|
|
254
256
|
|
|
@@ -342,8 +344,8 @@ const seenAfter = (seen: readonly SeenEntry[], items: readonly SourceItem[]): Se
|
|
|
342
344
|
|
|
343
345
|
const recordOf = (record: SourceRecord | undefined, result: SourceResult, now: number): SourceRecord =>
|
|
344
346
|
result.ok
|
|
345
|
-
? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null }
|
|
346
|
-
: { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now }
|
|
347
|
+
? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null, ranAt: now }
|
|
348
|
+
: { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now, ranAt: now }
|
|
347
349
|
|
|
348
350
|
/**
|
|
349
351
|
* The state after `runs` for the sources configured as `names`: a run records its outcome, a source without a run keeps
|
|
@@ -385,6 +387,7 @@ const isSourceRecord = (value: unknown): boolean =>
|
|
|
385
387
|
isRecord(value) &&
|
|
386
388
|
(value.seen === null || (Array.isArray(value.seen) && value.seen.every(isSeenEntry))) &&
|
|
387
389
|
(value.failingSince === null || typeof value.failingSince === 'number') &&
|
|
390
|
+
(value.ranAt === undefined || typeof value.ranAt === 'number') &&
|
|
388
391
|
(value.tickets === undefined || (isRecord(value.tickets) && Object.values(value.tickets).every(isTicketRecord)))
|
|
389
392
|
|
|
390
393
|
export const isSourceState = (value: unknown): value is SourceState =>
|