jackherd 1.9.0 → 1.11.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 (228) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +65 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
  5. package/dist/config.d.ts +99 -4
  6. package/dist/config.js +105 -29
  7. package/dist/plan-format.d.ts +51 -6
  8. package/dist/plan-format.js +49 -7
  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/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +57 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/rules/rules/kit/pull-requests.md +2 -0
  50. package/kit/workflow/skills/follow/SKILL.md +3 -10
  51. package/kit/workflow/skills/implement/SKILL.md +8 -6
  52. package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
  53. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  54. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  55. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  56. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  57. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  58. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  59. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  60. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  61. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  62. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  64. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  65. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  66. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  67. package/kit/workflow/skills/plan/SKILL.md +6 -5
  68. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  69. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  70. package/package.json +3 -1
  71. package/recipes/README.md +2 -1
  72. package/recipes/sources.md +5 -1
  73. package/schemas/jackherd.schema.json +139 -5
  74. package/src/check/agent-text-reviewers.ts +23 -0
  75. package/src/check/command.ts +2 -0
  76. package/src/check/plan-structure-check.ts +49 -2
  77. package/src/check/plan.ts +8 -1
  78. package/src/check/schema.ts +9 -0
  79. package/src/check/skill-budget-defaults.ts +55 -0
  80. package/src/check/skill-budgets.ts +185 -0
  81. package/src/cli.ts +2 -0
  82. package/src/config/auto-window.ts +8 -0
  83. package/src/config/link-kinds.ts +7 -1
  84. package/src/config/schema.ts +82 -25
  85. package/src/config/tones.ts +27 -0
  86. package/src/lane/close-board.ts +106 -0
  87. package/src/lane/close-check.ts +183 -0
  88. package/src/lane/close-command-line.ts +11 -0
  89. package/src/lane/close-command.ts +217 -0
  90. package/src/lane/close-options.ts +43 -0
  91. package/src/lane/close-survey.ts +83 -0
  92. package/src/lane/close.ts +141 -0
  93. package/src/lane/command.ts +13 -0
  94. package/src/lane-prompt/additions.ts +14 -0
  95. package/src/lane-prompt/build.ts +74 -0
  96. package/src/lane-prompt/command.ts +93 -0
  97. package/src/lane-prompt/input.ts +157 -0
  98. package/src/lane-prompt/options.ts +70 -0
  99. package/src/lane-prompt/texts.ts +101 -0
  100. package/src/mermaid/command.ts +36 -0
  101. package/src/mermaid/problems.ts +48 -0
  102. package/src/plan-format/clock.ts +8 -0
  103. package/src/plan-format/dependencies.ts +9 -6
  104. package/src/plan-format/index.ts +1 -0
  105. package/src/plan-format/short-names.ts +71 -0
  106. package/src/plan-format/waiting.ts +22 -4
  107. package/src/plans/appointment-rows.ts +83 -0
  108. package/src/plans/ask-coordinator.ts +111 -0
  109. package/src/plans/ask-target.ts +36 -0
  110. package/src/plans/board-close.ts +85 -0
  111. package/src/plans/child-stop.ts +51 -0
  112. package/src/plans/closable-records.ts +34 -0
  113. package/src/plans/command.ts +3 -2
  114. package/src/plans/fetch.ts +26 -3
  115. package/src/plans/frame.ts +5 -12
  116. package/src/plans/git.ts +54 -5
  117. package/src/plans/groomed.ts +27 -0
  118. package/src/plans/herd-claim.ts +173 -0
  119. package/src/plans/herd-memory.ts +36 -25
  120. package/src/plans/lane-plans.ts +44 -13
  121. package/src/plans/lane-summary.ts +253 -45
  122. package/src/plans/layout-phase.ts +36 -11
  123. package/src/plans/layout-summary.ts +89 -18
  124. package/src/plans/layout.ts +502 -98
  125. package/src/plans/link-chips.ts +23 -4
  126. package/src/plans/live.ts +178 -23
  127. package/src/plans/next-rows.ts +44 -0
  128. package/src/plans/phase-ids.ts +8 -0
  129. package/src/plans/phase-source.ts +15 -0
  130. package/src/plans/plan-cache.ts +28 -0
  131. package/src/plans/plan-heads.ts +54 -0
  132. package/src/plans/planned.ts +22 -0
  133. package/src/plans/pull-requests.ts +17 -2
  134. package/src/plans/question-records.ts +48 -0
  135. package/src/plans/questions-read.ts +13 -0
  136. package/src/plans/read-cache.ts +8 -0
  137. package/src/plans/record-id.ts +4 -0
  138. package/src/plans/restart-decision.ts +13 -4
  139. package/src/plans/row-actions.ts +52 -0
  140. package/src/plans/row-focus.ts +148 -0
  141. package/src/plans/row-targets.ts +7 -0
  142. package/src/plans/selection.ts +111 -18
  143. package/src/plans/sent-files-read.ts +6 -15
  144. package/src/plans/sent-files.ts +7 -14
  145. package/src/plans/settings.ts +12 -1
  146. package/src/plans/signalled-phase.ts +42 -0
  147. package/src/plans/signals-read.ts +12 -4
  148. package/src/plans/snapshot.ts +162 -81
  149. package/src/plans/source-asked.ts +19 -0
  150. package/src/plans/source-prefix.ts +17 -0
  151. package/src/plans/source-rows.ts +188 -0
  152. package/src/plans/sprint-export.ts +11 -11
  153. package/src/plans/sprint-page.ts +5 -5
  154. package/src/plans/subagent-phases.ts +95 -0
  155. package/src/plans/subagent-tasks.ts +51 -0
  156. package/src/plans/subagents-read.ts +66 -17
  157. package/src/plans/subagents.ts +64 -6
  158. package/src/plans/supervisor.ts +14 -4
  159. package/src/plans/symbols.ts +3 -0
  160. package/src/plans/task-name.ts +34 -0
  161. package/src/plans/texts.ts +48 -6
  162. package/src/plans/tones.ts +30 -0
  163. package/src/plans/view.ts +25 -11
  164. package/src/plans/waiting-click.ts +295 -35
  165. package/src/plans/waiting-records.ts +39 -0
  166. package/src/plans/waiting-window.ts +8 -0
  167. package/src/preview/command.ts +25 -3
  168. package/src/shared/private-location.ts +89 -0
  169. package/src/shared/stat-cache.ts +48 -0
  170. package/src/sync/kit-source.ts +29 -10
  171. package/src/sync/kit.ts +2 -0
  172. package/src/sync/manifest.ts +2 -0
  173. package/src/sync/plan-ask-rules.ts +94 -0
  174. package/src/sync/plan-hooks.ts +2 -0
  175. package/src/sync/render.ts +3 -3
  176. package/src/sync/settings-hooks.ts +4 -1
  177. package/src/sync/sync-plan.ts +1 -0
  178. package/src/sync/sync.ts +24 -3
  179. package/src/task-signals/claude-read.ts +27 -10
  180. package/src/terminal/text.ts +16 -3
  181. package/src/terminal/types.ts +4 -0
  182. package/src/version/skill-diff.ts +46 -0
  183. package/src/vocabulary/index.ts +26 -6
  184. package/src/waiting/command.ts +22 -7
  185. package/src/watch/appointments.ts +1 -2
  186. package/src/watch/asking-lanes.ts +40 -0
  187. package/src/watch/base-tip.ts +11 -0
  188. package/src/watch/command.ts +330 -105
  189. package/src/watch/heartbeat-progress.ts +6 -18
  190. package/src/watch/kit-update.ts +28 -9
  191. package/src/watch/lane-name.ts +6 -0
  192. package/src/watch/lane-progress.ts +40 -8
  193. package/src/watch/lane-report-file.ts +20 -5
  194. package/src/watch/lane-report.ts +32 -4
  195. package/src/watch/lane-sources.ts +31 -3
  196. package/src/watch/main-checkout.ts +34 -0
  197. package/src/watch/plan-badges-read.ts +22 -9
  198. package/src/watch/plan-badges.ts +10 -2
  199. package/src/watch/pull-requests.ts +6 -2
  200. package/src/watch/reference-paths.ts +87 -0
  201. package/src/watch/report-questions.ts +4 -3
  202. package/src/watch/running-subagents.ts +43 -0
  203. package/src/watch/skill-change.ts +124 -32
  204. package/src/watch/source-change.ts +48 -0
  205. package/src/watch/source-entries.ts +55 -0
  206. package/src/watch/source-fields.ts +1 -0
  207. package/src/watch/source-statuses.ts +60 -0
  208. package/src/watch/source-tickets.ts +21 -1
  209. package/src/watch/sources.ts +47 -4
  210. package/src/watch/stream-output.ts +4 -3
  211. package/src/watch/task-parallel-auto.ts +208 -0
  212. package/src/watch/task-parallel-data-paths.ts +46 -0
  213. package/src/watch/task-parallel-input.ts +5 -15
  214. package/src/watch/task-parallel-read.ts +3 -1
  215. package/src/watch/task-parallel.ts +36 -7
  216. package/src/watch/task-signal-missing.ts +69 -0
  217. package/src/watch/task-signal-read.ts +29 -0
  218. package/src/watch/texts.ts +10 -0
  219. package/src/watch/update-available.ts +6 -3
  220. package/src/watch/watch-events.ts +23 -0
  221. package/src/watch/watcher-lock.ts +25 -29
  222. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  223. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  224. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  225. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  226. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  227. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  228. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -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
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ lineText,
3
+ reportsItem,
4
+ type SeenEntry,
5
+ type SourceEntry,
6
+ type SourceItem,
7
+ type SourceRecord,
8
+ type SourceRun,
9
+ type SourceSettings,
10
+ seenMapOf,
11
+ storedValue,
12
+ } from './sources'
13
+
14
+ /** The most entries a source keeps for the herd. */
15
+ export const ENTRY_LIMIT = 20
16
+
17
+ const shownAt = ({ changedAt, seenAt }: SourceEntry): number => changedAt ?? seenAt
18
+
19
+ const entryOf = (
20
+ item: SourceItem,
21
+ known: SourceEntry | undefined,
22
+ seen: ReadonlyMap<string, SeenEntry> | null,
23
+ settings: SourceSettings,
24
+ now: number,
25
+ ): SourceEntry => ({
26
+ id: item.id,
27
+ title: storedValue(lineText(item.title)),
28
+ url: item.url,
29
+ count: item.count,
30
+ seenAt: known?.seenAt ?? now,
31
+ changedAt: seen && reportsItem(seen, item, settings) ? now : (known?.changedAt ?? null),
32
+ })
33
+
34
+ /**
35
+ * The entries a source that is no ticket keeps after `run`: those of a successful run, at most `ENTRY_LIMIT`, newest
36
+ * change first, each with the time the watcher first saw it and the time it last reported a change of it (none for the
37
+ * baseline run). A failed run keeps what the source had; a ticket source keeps none (its tickets are the list).
38
+ */
39
+ export const entriesAfter = (
40
+ previous: SourceRecord | undefined,
41
+ run: SourceRun | undefined,
42
+ settings: SourceSettings | undefined,
43
+ now: number,
44
+ ): SourceEntry[] | undefined => {
45
+ if (!settings || settings.ticket) return undefined
46
+ if (!run?.result.ok) return previous?.entries
47
+ const seen = seenMapOf(previous)
48
+ const known = new Map((previous?.entries ?? []).map((entry) => [entry.id, entry]))
49
+ return run.result.items
50
+ .map((item) => entryOf(item, known.get(item.id), seen, settings, now))
51
+ .map((entry, index) => ({ entry, index }))
52
+ .sort((a, b) => shownAt(b.entry) - shownAt(a.entry) || a.index - b.index)
53
+ .map(({ entry }) => entry)
54
+ .slice(0, ENTRY_LIMIT)
55
+ }
@@ -7,6 +7,7 @@ export const SOURCE_FIELD = {
7
7
  body: 'body',
8
8
  author: 'author',
9
9
  status: 'status',
10
+ count: 'count',
10
11
  } as const
11
12
 
12
13
  /** The fields a reader sees of an entry: a change of one of them is a change of the entry (`contentFingerprint`). */
@@ -0,0 +1,60 @@
1
+ import { isFresh, NEW_FOR_MS } from './plan-badges'
2
+ import { previewUrlOf } from './previews'
3
+ import { lineText, type SourceEntry, type SourceRecord } from './sources'
4
+ import type { WatchState } from './watch-events'
5
+
6
+ /** The newest entry a source reported, as the herd draws it; `isNew` while it is younger than `NEW_FOR_MS`. */
7
+ export type LatestStatus = { title: string; url: string | null; isNew: boolean }
8
+
9
+ /** An entry as the herd lists it under its source: `at` is when it last changed, else when the watcher first saw it; `isNew` only for a reported change younger than `NEW_FOR_MS`. */
10
+ export type EntryStatus = {
11
+ id: string
12
+ title: string
13
+ url: string | null
14
+ count: string | null
15
+ at: number
16
+ isNew: boolean
17
+ }
18
+
19
+ /** What the herd shows of a source: when it ran last, since when it fails, its newest report and its entries, newest change first. */
20
+ export type SourceStatus = {
21
+ name: string
22
+ ranAt: number | null
23
+ failingSince: number | null
24
+ latest: LatestStatus | null
25
+ entries: EntryStatus[]
26
+ }
27
+
28
+ const entryStatusOf = (entry: SourceEntry, now: number): EntryStatus => ({
29
+ id: entry.id,
30
+ title: lineText(entry.title),
31
+ url: entry.url === null ? null : previewUrlOf(entry.url, undefined),
32
+ count: entry.count,
33
+ at: entry.changedAt ?? entry.seenAt,
34
+ isNew: entry.changedAt !== null && now - entry.changedAt < NEW_FOR_MS,
35
+ })
36
+
37
+ const latestOf = (latest: SourceRecord['latest'], now: number): LatestStatus | null =>
38
+ latest
39
+ ? {
40
+ title: lineText(latest.title),
41
+ url: latest.url === null ? null : previewUrlOf(latest.url, undefined),
42
+ isNew: now - latest.at < NEW_FOR_MS,
43
+ }
44
+ : null
45
+
46
+ const statusOf = (name: string, record: SourceRecord | undefined, now: number): SourceStatus => ({
47
+ name,
48
+ ranAt: record?.ranAt ?? null,
49
+ failingSince: record?.failingSince ?? null,
50
+ latest: latestOf(record?.latest, now),
51
+ entries: (record?.entries ?? []).map((entry) => entryStatusOf(entry, now)),
52
+ })
53
+
54
+ /**
55
+ * The status of each source in `names`, in their order, from the watcher's saved state alone. A name the watcher has
56
+ * not run yet shows without a run; a title is drawn as one line and the URL is checked again, as for the tickets, and a
57
+ * state older than `STALE_AFTER_MS` shows none.
58
+ */
59
+ export const sourceStatusesOf = (state: WatchState, names: readonly string[], now: number): SourceStatus[] =>
60
+ isFresh(state.savedAt, now) ? names.map((name) => statusOf(name, state.sources[name], now)) : []
@@ -1,4 +1,7 @@
1
+ import { entriesAfter } from './source-entries'
1
2
  import {
3
+ type LatestReport,
4
+ lineText,
2
5
  recordSources,
3
6
  reportsItem,
4
7
  SEEN_LIMIT,
@@ -9,6 +12,7 @@ import {
9
12
  type SourceSettings,
10
13
  type SourceState,
11
14
  seenMapOf,
15
+ storedValue,
12
16
  type TicketRecord,
13
17
  } from './sources'
14
18
 
@@ -39,6 +43,19 @@ const ticketsAfter = (
39
43
  )
40
44
  }
41
45
 
46
+ /** The newest entry this run reported (the last of them), else what was recorded; failures and baselines record nothing. */
47
+ const latestAfter = (
48
+ previous: SourceRecord | undefined,
49
+ run: SourceRun | undefined,
50
+ settings: SourceSettings | undefined,
51
+ now: number,
52
+ ): LatestReport | undefined => {
53
+ const seen = seenMapOf(previous)
54
+ if (!seen || !settings || !run?.result.ok) return previous?.latest
55
+ const reported = run.result.items.filter((item) => reportsItem(seen, item, settings)).at(-1)
56
+ return reported ? { title: storedValue(lineText(reported.title)), url: reported.url, at: now } : previous?.latest
57
+ }
58
+
42
59
  const withoutTickets = ({ tickets: _tickets, ...record }: SourceRecord): SourceRecord => record
43
60
 
44
61
  /** The tickets a source keeps: those of a successful run, else the ones it had; none for a source not marked. */
@@ -71,7 +88,10 @@ const recordTickets = (
71
88
  const source = settings.find((candidate) => candidate.name === name)
72
89
  const run = runs.find((candidate) => candidate.name === name)
73
90
  const tickets = ticketsOf(previous[name], run, source, now)
74
- return [name, tickets ? { ...withoutTickets(record), tickets } : withoutTickets(record)]
91
+ const latest = latestAfter(previous[name], run, source, now)
92
+ const entries = entriesAfter(previous[name], run, source, now)
93
+ const base = { ...withoutTickets(record), ...(latest ? { latest } : {}), ...(entries ? { entries } : {}) }
94
+ return [name, tickets ? { ...base, tickets } : base]
75
95
  }),
76
96
  )
77
97
 
@@ -67,6 +67,8 @@ export type SourceItem = {
67
67
  author: string | null
68
68
  /** `map.status` as the state keeps it (`storedValue`), null when not mapped or empty. */
69
69
  status: string | null
70
+ /** `map.count` as one line (how often an error happened); null when not mapped or empty. */
71
+ count: string | null
70
72
  fingerprint: string
71
73
  /** The `reportOn` fields as the state keeps them (`storedValue`); empty without `reportOn`. */
72
74
  values: Record<string, string>
@@ -86,6 +88,22 @@ export type SeenEntry = [string, string] | [string, string, Record<string, strin
86
88
  */
87
89
  export type TicketRecord = { url: string | null; status: string | null; changedAt: number | null }
88
90
 
91
+ /** The newest entry a source reported: its title as one line, its checked URL or null, and the epoch milliseconds of the run that reported it. */
92
+ export type LatestReport = { title: string; url: string | null; at: number }
93
+
94
+ /**
95
+ * An entry of a source as the herd lists it: its title as one line, its checked URL, its count, when the watcher first
96
+ * saw it (`seenAt`) and when it last reported a change of it (`changedAt`, null while only the baseline knows it).
97
+ */
98
+ export type SourceEntry = {
99
+ id: string
100
+ title: string
101
+ url: string | null
102
+ count: string | null
103
+ seenAt: number
104
+ changedAt: number | null
105
+ }
106
+
89
107
  /**
90
108
  * What the watcher keeps per source: `seen` holds a `SeenEntry` per id, the entries seen most recently last, or null
91
109
  * before the first successful run (which is a baseline); `failingSince` is the epoch milliseconds of the first failed
@@ -94,8 +112,14 @@ export type TicketRecord = { url: string | null; status: string | null; changedA
94
112
  export type SourceRecord = {
95
113
  seen: SeenEntry[] | null
96
114
  failingSince: number | null
115
+ /** When its last run started, failed or not (`isSourceDue`); absent in a state written before it was kept: never. */
116
+ ranAt?: number
97
117
  /** Of a ticket source only: the entries of its last successful run by id. */
98
118
  tickets?: Record<string, TicketRecord>
119
+ /** The newest entry the source reported, kept for the herd's row of the source; absent until one was reported. */
120
+ latest?: LatestReport
121
+ /** Of a source that is no ticket: the entries of its last successful run for the herd's expanded row, newest change first. */
122
+ entries?: SourceEntry[]
99
123
  }
100
124
 
101
125
  export type SourceState = Record<string, SourceRecord>
@@ -143,7 +167,7 @@ const scalarText = (value: unknown): string | null => {
143
167
  export const textAt = (entry: unknown, path: SourcePath | undefined): string | null =>
144
168
  path === undefined ? null : scalarText(selectPath(entry, path))
145
169
 
146
- type MappedFields = Omit<SourceItem, 'fingerprint' | 'values' | 'status'>
170
+ type MappedFields = Omit<SourceItem, 'fingerprint' | 'values' | 'status' | 'count'>
147
171
 
148
172
  const hashOf = (text: string, length: number): string =>
149
173
  createHash('sha256').update(text).digest('hex').slice(0, length)
@@ -189,6 +213,9 @@ const valuesOf = (
189
213
  const statusOf = (entry: unknown, map: SourceMap): string | null =>
190
214
  storedValue(lineText(textAt(entry, map.status) ?? '')) || null
191
215
 
216
+ const countOf = (entry: unknown, map: SourceMap): string | null =>
217
+ storedValue(lineText(textAt(entry, map.count) ?? '')) || null
218
+
192
219
  /** One entry after `map`, or null when its id, or a mapped updatedAt, is missing or no text; unmapped it is `''`. */
193
220
  const sourceItemOf = (entry: unknown, settings: Pick<SourceSettings, 'map' | 'reportOn'>): SourceItem | null => {
194
221
  const { map } = settings
@@ -207,6 +234,7 @@ const sourceItemOf = (entry: unknown, settings: Pick<SourceSettings, 'map' | 're
207
234
  return {
208
235
  ...fields,
209
236
  status: statusOf(entry, map),
237
+ count: countOf(entry, map),
210
238
  fingerprint: contentFingerprint(fields, map),
211
239
  values: valuesOf(entry, fields, settings),
212
240
  }
@@ -248,7 +276,7 @@ export const parseSourceOutput = (
248
276
  return { ok: true, items: uniqueById(items as SourceItem[]) }
249
277
  }
250
278
 
251
- /** Whether a source last started at `lastRunAt` (undefined: never in this process) is due again at `now`. */
279
+ /** Whether a source last started at `lastRunAt` (`SourceRecord.ranAt`; undefined: never) is due again at `now`. */
252
280
  export const isSourceDue = (lastRunAt: number | undefined, everyMinutes: number, now: number): boolean =>
253
281
  lastRunAt === undefined || now - lastRunAt >= everyMinutes * MINUTE_MS
254
282
 
@@ -342,8 +370,8 @@ const seenAfter = (seen: readonly SeenEntry[], items: readonly SourceItem[]): Se
342
370
 
343
371
  const recordOf = (record: SourceRecord | undefined, result: SourceResult, now: number): SourceRecord =>
344
372
  result.ok
345
- ? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null }
346
- : { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now }
373
+ ? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null, ranAt: now }
374
+ : { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now, ranAt: now }
347
375
 
348
376
  /**
349
377
  * The state after `runs` for the sources configured as `names`: a run records its outcome, a source without a run keeps
@@ -381,10 +409,25 @@ const isTicketRecord = (value: unknown): boolean =>
381
409
  isTextOrNull(value.status) &&
382
410
  (value.changedAt === null || typeof value.changedAt === 'number')
383
411
 
412
+ const isLatestReport = (value: unknown): boolean =>
413
+ isRecord(value) && typeof value.title === 'string' && isTextOrNull(value.url) && typeof value.at === 'number'
414
+
415
+ const isSourceEntry = (value: unknown): boolean =>
416
+ isRecord(value) &&
417
+ typeof value.id === 'string' &&
418
+ typeof value.title === 'string' &&
419
+ isTextOrNull(value.url) &&
420
+ isTextOrNull(value.count) &&
421
+ typeof value.seenAt === 'number' &&
422
+ (value.changedAt === null || typeof value.changedAt === 'number')
423
+
384
424
  const isSourceRecord = (value: unknown): boolean =>
385
425
  isRecord(value) &&
426
+ (value.entries === undefined || (Array.isArray(value.entries) && value.entries.every(isSourceEntry))) &&
427
+ (value.latest === undefined || isLatestReport(value.latest)) &&
386
428
  (value.seen === null || (Array.isArray(value.seen) && value.seen.every(isSeenEntry))) &&
387
429
  (value.failingSince === null || typeof value.failingSince === 'number') &&
430
+ (value.ranAt === undefined || typeof value.ranAt === 'number') &&
388
431
  (value.tickets === undefined || (isRecord(value.tickets) && Object.values(value.tickets).every(isTicketRecord)))
389
432
 
390
433
  export const isSourceState = (value: unknown): value is SourceState =>
@@ -17,7 +17,8 @@ const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes, rests:
17
17
 
18
18
  /**
19
19
  * The heartbeat of a stream, as one line, since every line of a stream reaches the coordinator as a
20
- * message of its own: `=== heartbeat <time> · <lane> <status> · …`.
20
+ * message of its own: `=== heartbeat <time> · <lane> <status> · …`. `standing` are the lines that stay until
21
+ * their cause is gone (what waits on the user, the main checkout behind its base), each one entry at the end.
21
22
  */
22
23
  export const streamHeartbeatLine = (
23
24
  time: string,
@@ -25,12 +26,12 @@ export const streamHeartbeatLine = (
25
26
  current: LaneSnapshot,
26
27
  notes: LaneNotes,
27
28
  rests: LaneNotes = {},
28
- waiting: string | null = null,
29
+ standing: readonly string[] = [],
29
30
  ): string => {
30
31
  const states = lanes.map((lane) => laneState(lane, current, notes, rests))
31
32
  return [
32
33
  `=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`,
33
34
  ...(states.length > 0 ? states : ['no lanes']),
34
- ...(waiting === null ? [] : [terminalSafe(waiting).replaceAll(SEPARATOR, ' / ')]),
35
+ ...standing.map((line) => terminalSafe(line).replaceAll(SEPARATOR, ' / ')),
35
36
  ].join(SEPARATOR)
36
37
  }