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.
Files changed (261) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/INSTALL.md +3 -1
  3. package/README.md +75 -64
  4. package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
  5. package/dist/config.d.ts +45 -4
  6. package/dist/config.js +84 -27
  7. package/dist/plan-format.d.ts +63 -7
  8. package/dist/plan-format.js +66 -12
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  15. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  16. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  17. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  18. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  19. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  20. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  21. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  22. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  23. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  24. package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
  25. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  26. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  27. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  28. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  29. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  30. package/kit/hooks/hooks/open-question/index.ts +26 -0
  31. package/kit/hooks/hooks/open-question/record.ts +135 -0
  32. package/kit/hooks/hooks/open-question/run.ts +52 -0
  33. package/kit/hooks/hooks/open-question/store.ts +39 -0
  34. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  35. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  36. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  37. package/kit/hooks/hooks/sent-file/store.ts +9 -0
  38. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  39. package/kit/hooks/hooks/shared/config.ts +7 -0
  40. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  41. package/kit/hooks/hooks/shared/files.ts +7 -0
  42. package/kit/hooks/hooks/shared/lane.ts +1 -1
  43. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  44. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  45. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  46. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  47. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  48. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  49. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  50. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  51. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  52. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  53. package/kit/hooks/hooks.json +21 -4
  54. package/kit/hooks/statusline.sh +1 -1
  55. package/kit/review/agents/skill-review.md +34 -0
  56. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  57. package/kit/workflow/skills/follow/SKILL.md +1 -8
  58. package/kit/workflow/skills/implement/SKILL.md +10 -6
  59. package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
  60. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  61. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  62. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  63. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  64. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  65. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  66. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  67. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  68. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  69. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  70. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  71. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  72. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  73. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  74. package/kit/workflow/skills/plan/SKILL.md +6 -5
  75. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  76. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  77. package/package.json +4 -1
  78. package/recipes/README.md +2 -1
  79. package/recipes/sources.md +1 -1
  80. package/schemas/jackherd.schema.json +85 -7
  81. package/src/check/agent-text-reviewers.ts +23 -0
  82. package/src/check/command.ts +2 -0
  83. package/src/check/plan-structure-check.ts +49 -2
  84. package/src/check/plan.ts +8 -1
  85. package/src/check/schema.ts +9 -0
  86. package/src/check/skill-budget-defaults.ts +55 -0
  87. package/src/check/skill-budgets.ts +185 -0
  88. package/src/cli.ts +4 -0
  89. package/src/config/auto-window.ts +8 -0
  90. package/src/config/link-kinds.ts +7 -1
  91. package/src/config/schema.ts +80 -26
  92. package/src/context/fill.ts +12 -0
  93. package/src/context/session-log.ts +43 -1
  94. package/src/lane/close-board.ts +106 -0
  95. package/src/lane/close-check.ts +183 -0
  96. package/src/lane/close-command-line.ts +11 -0
  97. package/src/lane/close-command.ts +217 -0
  98. package/src/lane/close-options.ts +43 -0
  99. package/src/lane/close-survey.ts +83 -0
  100. package/src/lane/close.ts +141 -0
  101. package/src/lane/command.ts +13 -0
  102. package/src/lane-prompt/build.ts +74 -0
  103. package/src/lane-prompt/command.ts +87 -0
  104. package/src/lane-prompt/input.ts +116 -0
  105. package/src/lane-prompt/options.ts +68 -0
  106. package/src/lane-prompt/texts.ts +101 -0
  107. package/src/plan-format/clock.ts +12 -0
  108. package/src/plan-format/dependencies.ts +9 -9
  109. package/src/plan-format/index.ts +1 -0
  110. package/src/plan-format/lanes.ts +30 -5
  111. package/src/plan-format/short-names.ts +71 -0
  112. package/src/plan-format/terminal-text.ts +4 -0
  113. package/src/plan-format/waiting.ts +53 -0
  114. package/src/plans/active-tasks.ts +60 -0
  115. package/src/plans/appointment-rows.ts +59 -0
  116. package/src/plans/ask-coordinator.ts +77 -0
  117. package/src/plans/ask-target.ts +25 -0
  118. package/src/plans/child-stop.ts +51 -0
  119. package/src/plans/closable-records.ts +34 -0
  120. package/src/plans/command.ts +3 -2
  121. package/src/plans/current-phase.ts +17 -7
  122. package/src/plans/current-task.ts +63 -5
  123. package/src/plans/fetch.ts +26 -3
  124. package/src/plans/git.ts +19 -5
  125. package/src/plans/herd-claim.ts +173 -0
  126. package/src/plans/herd-memory.ts +36 -25
  127. package/src/plans/lane-plans.ts +44 -13
  128. package/src/plans/lane-standing.ts +9 -4
  129. package/src/plans/lane-summary.ts +367 -92
  130. package/src/plans/lane-tickets.ts +15 -0
  131. package/src/plans/layout-phase.ts +80 -19
  132. package/src/plans/layout-summary.ts +163 -74
  133. package/src/plans/layout.ts +472 -114
  134. package/src/plans/link-chips.ts +1 -0
  135. package/src/plans/live.ts +119 -10
  136. package/src/plans/phase-ids.ts +8 -0
  137. package/src/plans/phase-models.ts +1 -1
  138. package/src/plans/phase-ranges.ts +45 -0
  139. package/src/plans/phase-source.ts +15 -0
  140. package/src/plans/plan-cache.ts +28 -0
  141. package/src/plans/plan-heads.ts +54 -0
  142. package/src/plans/pull-requests.ts +23 -2
  143. package/src/plans/question-records.ts +48 -0
  144. package/src/plans/questions-read.ts +13 -0
  145. package/src/plans/read-cache.ts +8 -0
  146. package/src/plans/record-id.ts +4 -0
  147. package/src/plans/restart-decision.ts +13 -4
  148. package/src/plans/selection.ts +117 -34
  149. package/src/plans/sent-files-read.ts +13 -0
  150. package/src/plans/sent-files.ts +114 -0
  151. package/src/plans/settings.ts +2 -1
  152. package/src/plans/signalled-phase.ts +42 -0
  153. package/src/plans/signals-read.ts +62 -0
  154. package/src/plans/snapshot.ts +279 -60
  155. package/src/plans/sprint-export.ts +11 -11
  156. package/src/plans/sprint-page.ts +5 -5
  157. package/src/plans/subagent-phases.ts +95 -0
  158. package/src/plans/subagent-tasks.ts +51 -0
  159. package/src/plans/subagents-read.ts +131 -0
  160. package/src/plans/subagents.ts +190 -0
  161. package/src/plans/supervisor.ts +14 -4
  162. package/src/plans/symbols.ts +3 -0
  163. package/src/plans/task-name.ts +34 -0
  164. package/src/plans/texts.ts +36 -14
  165. package/src/plans/unlisted-lanes.ts +10 -5
  166. package/src/plans/view.ts +20 -12
  167. package/src/plans/waiting-click.ts +231 -0
  168. package/src/plans/waiting-records.ts +39 -0
  169. package/src/plans/waiting-window.ts +8 -0
  170. package/src/preview/command.ts +105 -0
  171. package/src/preview/embed.ts +209 -0
  172. package/src/preview/images.ts +89 -0
  173. package/src/preview/open.ts +12 -0
  174. package/src/preview/options.ts +32 -0
  175. package/src/preview/page.ts +89 -0
  176. package/src/preview/render.ts +41 -0
  177. package/src/settled/board-row.ts +1 -2
  178. package/src/shared/is-record.ts +2 -0
  179. package/src/shared/private-location.ts +89 -0
  180. package/src/shared/private-records.ts +48 -0
  181. package/src/shared/stat-cache.ts +48 -0
  182. package/src/sync/kit-source.ts +29 -10
  183. package/src/sync/kit.ts +2 -0
  184. package/src/sync/manifest.ts +2 -0
  185. package/src/sync/plan-ask-rules.ts +94 -0
  186. package/src/sync/plan-hooks.ts +2 -0
  187. package/src/sync/render.ts +3 -3
  188. package/src/sync/settings-hooks.ts +4 -1
  189. package/src/sync/sync-plan.ts +1 -0
  190. package/src/sync/sync.ts +24 -3
  191. package/src/task-signals/agent.ts +15 -0
  192. package/src/task-signals/claude-read.ts +89 -0
  193. package/src/task-signals/claude-signals.ts +112 -0
  194. package/src/task-signals/command.ts +71 -0
  195. package/src/task-signals/fixtures/session.jsonl +7 -0
  196. package/src/task-signals/matcher.ts +96 -0
  197. package/src/task-signals/record-store.ts +40 -0
  198. package/src/task-signals/record.ts +68 -0
  199. package/src/task-signals/text.ts +13 -0
  200. package/src/task-signals/types.ts +14 -0
  201. package/src/terminal/open.ts +13 -6
  202. package/src/terminal/text.ts +16 -4
  203. package/src/terminal/width.ts +2 -1
  204. package/src/version/skill-diff.ts +46 -0
  205. package/src/vocabulary/index.ts +44 -6
  206. package/src/waiting/command.ts +50 -0
  207. package/src/watch/appointments.ts +2 -4
  208. package/src/watch/approvals.ts +2 -2
  209. package/src/watch/asking-lanes.ts +40 -0
  210. package/src/watch/base-tip.ts +11 -0
  211. package/src/watch/command.ts +401 -90
  212. package/src/watch/failed-tests.ts +56 -10
  213. package/src/watch/failure-read.ts +18 -6
  214. package/src/watch/failures.ts +29 -5
  215. package/src/watch/feedback.ts +1 -1
  216. package/src/watch/gh-process.ts +9 -3
  217. package/src/watch/heartbeat-progress.ts +6 -18
  218. package/src/watch/json-guards.ts +0 -3
  219. package/src/watch/kit-update.ts +28 -9
  220. package/src/watch/lane-fill.ts +1 -1
  221. package/src/watch/lane-model.ts +1 -1
  222. package/src/watch/lane-name.ts +6 -0
  223. package/src/watch/lane-parallel.ts +1 -1
  224. package/src/watch/lane-progress.ts +58 -14
  225. package/src/watch/lane-report-file.ts +20 -5
  226. package/src/watch/lane-report.ts +37 -5
  227. package/src/watch/lane-rest.ts +28 -0
  228. package/src/watch/lane-sources.ts +31 -3
  229. package/src/watch/main-checkout.ts +34 -0
  230. package/src/watch/plan-badges-read.ts +13 -7
  231. package/src/watch/plan-badges.ts +9 -2
  232. package/src/watch/previews.ts +1 -1
  233. package/src/watch/pull-requests.ts +8 -3
  234. package/src/watch/reference-paths.ts +87 -0
  235. package/src/watch/running-subagents.ts +43 -0
  236. package/src/watch/skill-change.ts +124 -32
  237. package/src/watch/source-change.ts +48 -0
  238. package/src/watch/source-path.ts +1 -1
  239. package/src/watch/sources.ts +7 -4
  240. package/src/watch/stream-output.ts +14 -8
  241. package/src/watch/task-parallel-auto.ts +208 -0
  242. package/src/watch/task-parallel-data-paths.ts +46 -0
  243. package/src/watch/task-parallel-input.ts +108 -0
  244. package/src/watch/task-parallel-read.ts +151 -0
  245. package/src/watch/task-parallel-touched.ts +39 -0
  246. package/src/watch/task-parallel.ts +235 -0
  247. package/src/watch/task-signal-missing.ts +69 -0
  248. package/src/watch/task-signal-read.ts +29 -0
  249. package/src/watch/texts.ts +10 -0
  250. package/src/watch/update-available.ts +6 -3
  251. package/src/watch/waiting-line.ts +19 -0
  252. package/src/watch/watch-events.ts +36 -1
  253. package/src/watch/watcher-lock.ts +25 -29
  254. package/src/watch/workflow-runs.ts +2 -1
  255. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  256. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  257. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  258. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  259. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  260. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  261. 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 { BASE_REMOTE } from '../plans/settings'
4
- import { baseFetchNeeded, remoteTipOf } from './base-tip'
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 { readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
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(readFileSync(file, 'utf8'))
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 state = readState(join(directory, WATCHER_FILES.state))
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 }
@@ -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]] : []
@@ -1,6 +1,6 @@
1
+ import { isRecord } from '../shared/is-record'
1
2
  import { type FeedbackTarget, isReadTime } from './feedback'
2
3
  import { FEEDBACK_FORM, type FeedbackItem } from './feedback-item'
3
- import { isRecord } from './json-guards'
4
4
 
5
5
  export const PREVIEW_EVENT = 'preview'
6
6
 
@@ -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, parseArray } from './json-guards'
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 { mkdirSync, readFileSync } from 'node:fs'
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 { changedSections, SKILL_CHANGE, type SkillChange, skillChangeLine } from '../version/skill-diff'
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 { writePrivate } from './watcher-lock'
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
- join(packageRoot, 'kit', 'workflow', 'skills', 'orchestrate', 'SKILL.md')
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
- * (`git diff --no-index`) written to `directory` and the sections it touches, so the coordinator reads the change
24
- * instead of the whole skill. Unavailable without a snapshot, a readable skill or a diff.
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
- mkdirSync(directory, { recursive: true, mode: 0o700 })
33
- const before = join(directory, 'orchestrate-skill.before.md')
34
- writePrivate(before, snapshot)
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
- /** The line about the orchestrate skill after a kit update, for the kit the watcher started with. */
56
- export const skillLineFor = (kit: StartedKit | null, snapshot: string | null, directory: string): string =>
57
- skillChangeLine(
58
- kit
59
- ? skillChangeSince(snapshot, kit.packageRoot, directory)
60
- : { kind: SKILL_CHANGE.unavailable, reason: 'kit not located' },
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
+ }
@@ -1,4 +1,4 @@
1
- import { isRecord } from './json-guards'
1
+ import { isRecord } from '../shared/is-record'
2
2
 
3
3
  /**
4
4
  * Where a field sits in a source's JSON output: `.` is the whole output, else a chain of `.key`, `[n]` and `[-n]`
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { terminalSafe } from '../plan-format/terminal-text'
3
- import { isRecord } from './json-guards'
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 in this process) is due again at `now`. */
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 =>