jackherd 0.0.1 → 1.0.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 (250) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1121 -2
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +63 -5
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,117 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { type ChecksState, checksOf, type GhPullRequest, isGhPullRequest } from '../plans/pull-requests'
3
+ import { isRecord, parseArray } from './json-guards'
4
+
5
+ /** The states of a pull request in `gh pr list --json state`. */
6
+ export const PULL_REQUEST_STATE = { open: 'OPEN', merged: 'MERGED', closed: 'CLOSED' } as const
7
+
8
+ export type PullRequestLifecycle = (typeof PULL_REQUEST_STATE)[keyof typeof PULL_REQUEST_STATE]
9
+
10
+ /** A pull request as the lane watcher follows it, across open, merged and closed. */
11
+ export type WatchedPullRequest = {
12
+ number: number
13
+ branch: string
14
+ state: PullRequestLifecycle
15
+ checks: ChecksState
16
+ headCommit: string
17
+ mergeCommit: string | null
18
+ }
19
+
20
+ /** The rollup is optional: the light list has none. */
21
+ type GhWatchedPullRequest = Omit<GhPullRequest, 'statusCheckRollup'> & {
22
+ statusCheckRollup?: GhPullRequest['statusCheckRollup']
23
+ state: PullRequestLifecycle
24
+ headRefOid: string
25
+ mergeCommit: { oid: string } | null
26
+ }
27
+
28
+ /**
29
+ * The fields of the light list, `gh pr list --state all --json`: everything the watcher follows except the checks.
30
+ * `statusCheckRollup` is what made the old single call too heavy for GitHub's GraphQL (a 504 after ~10 s).
31
+ */
32
+ export const WATCHED_PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,state,headRefOid,mergeCommit'
33
+
34
+ /** The fields of the list of open pull requests, which is the only one that asks for their checks. */
35
+ export const OPEN_PULL_REQUEST_FIELDS = `${WATCHED_PULL_REQUEST_FIELDS},statusCheckRollup`
36
+
37
+ const GH_PR_LIST = 'gh pr list'
38
+
39
+ const isMergeCommit = (value: unknown): boolean => value === null || (isRecord(value) && typeof value.oid === 'string')
40
+
41
+ /** An entry of the light list has no `statusCheckRollup` at all; it reads as one with a null rollup. */
42
+ const withNullRollup = (value: unknown): unknown =>
43
+ isRecord(value) && !('statusCheckRollup' in value) ? { ...value, statusCheckRollup: null } : value
44
+
45
+ const isGhWatchedPullRequest = (value: unknown, host: string): value is GhWatchedPullRequest => {
46
+ if (!isGhPullRequest(value, host)) return false
47
+ const candidate = value as Record<string, unknown>
48
+ return (
49
+ (Object.values(PULL_REQUEST_STATE) as unknown[]).includes(candidate.state) &&
50
+ typeof candidate.headRefOid === 'string' &&
51
+ isMergeCommit(candidate.mergeCommit)
52
+ )
53
+ }
54
+
55
+ const watchedPullRequestsOf = (
56
+ json: string,
57
+ host: string,
58
+ prepare: (entry: unknown) => unknown,
59
+ ): WatchedPullRequest[] =>
60
+ parseArray(
61
+ json,
62
+ (entry): entry is GhWatchedPullRequest => isGhWatchedPullRequest(prepare(entry), host),
63
+ GH_PR_LIST,
64
+ ).map((pullRequest) => ({
65
+ number: pullRequest.number,
66
+ branch: terminalSafe(pullRequest.headRefName),
67
+ state: pullRequest.state,
68
+ checks: checksOf(pullRequest.statusCheckRollup ?? []),
69
+ headCommit: pullRequest.headRefOid,
70
+ mergeCommit: pullRequest.mergeCommit?.oid ?? null,
71
+ }))
72
+
73
+ const asIs = (entry: unknown): unknown => entry
74
+
75
+ /**
76
+ * Reads the JSON of `gh pr list --json <WATCHED_PULL_REQUEST_FIELDS>`, checked like the plans view's
77
+ * `parsePullRequests`; throws on anything else. The list carries no rollup, so its checks are `none`.
78
+ */
79
+ export const parseWatchedPullRequests = (json: string, host = 'github.com'): WatchedPullRequest[] =>
80
+ watchedPullRequestsOf(json, host, withNullRollup)
81
+
82
+ /**
83
+ * Reads the JSON of `gh pr list --state open --json <OPEN_PULL_REQUEST_FIELDS>`. The rollup must be there, null or a
84
+ * list: a field that went missing must not turn every check into `none` and silence the watcher; throws instead.
85
+ */
86
+ export const parseOpenPullRequests = (json: string, host = 'github.com'): WatchedPullRequest[] =>
87
+ watchedPullRequestsOf(json, host, asIs)
88
+
89
+ /** The pull request a lane's status line names: the open one on the branch, else the newest. */
90
+ export const latestPullRequestOf = (
91
+ pullRequests: readonly WatchedPullRequest[],
92
+ branch: string,
93
+ ): WatchedPullRequest | null => {
94
+ const onBranch = pullRequests.filter((pullRequest) => pullRequest.branch === branch)
95
+ const newestFirst = [...onBranch].sort((left, right) => right.number - left.number)
96
+ return newestFirst.find((pullRequest) => pullRequest.state === PULL_REQUEST_STATE.open) ?? newestFirst[0] ?? null
97
+ }
98
+
99
+ /**
100
+ * The light list and the list of open pull requests as one. An open pull request takes its head commit and checks
101
+ * from the open list, which read them together, so a verdict never belongs to another commit. One the light list
102
+ * already shows merged or closed keeps the light entry with no checks (the two lists are read concurrently, so the open list may still hold a pull request that has ended);
103
+ * one the open list lacks stays as listed (it ended in between and the next read shows it). An open pull request
104
+ * older than the light list's limit is added at the end.
105
+ */
106
+ export const mergeCheckedPullRequests = (
107
+ listed: readonly WatchedPullRequest[],
108
+ open: readonly WatchedPullRequest[],
109
+ ): WatchedPullRequest[] => {
110
+ const openByNumber = new Map(open.map((pullRequest) => [pullRequest.number, pullRequest]))
111
+ const known = new Set(listed.map(({ number }) => number))
112
+ const checked = listed.map((pullRequest) => {
113
+ const withChecks = openByNumber.get(pullRequest.number)
114
+ return pullRequest.state === PULL_REQUEST_STATE.open && withChecks ? withChecks : pullRequest
115
+ })
116
+ return [...checked, ...open.filter(({ number }) => !known.has(number))]
117
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The last line of a report without `--stream`, written for the model that started the call: the
3
+ * watcher is gone after it. With a Monitor tool a stream keeps reporting without a restart; without one
4
+ * (Bedrock, Vertex, Foundry, telemetry off) the restart after every report is the way it works.
5
+ */
6
+ export const SINGLE_REPORT_NOTICE =
7
+ 'watch ended after this report. Start it once with --stream under the Monitor tool: the stream stays armed and reports every change without a restart.'
8
+
9
+ export const NO_MONITOR_NOTICE =
10
+ 'watch ended after this report. This session has no Monitor tool, so a single call is the way it works here: start it again as a background Bash call (run_in_background) after you acted on this report.'
11
+
12
+ export const withSingleReportNotice = (lines: readonly string[], monitorAvailable: boolean): string[] => [
13
+ ...lines,
14
+ monitorAvailable ? SINGLE_REPORT_NOTICE : NO_MONITOR_NOTICE,
15
+ ]
@@ -0,0 +1,61 @@
1
+ import { mkdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { errorText } from '../plans/error-text'
4
+ import { changedSections, SKILL_CHANGE, type SkillChange, skillChangeLine } from '../version/skill-diff'
5
+ import type { StartedKit } from '../version/started-kit'
6
+ import { writePrivate } from './watcher-lock'
7
+
8
+ /** 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')
11
+
12
+ /** The orchestrate skill's text in the kit at `packageRoot`; null when it cannot be read. */
13
+ export const readOrchestrateSkill = (packageRoot: string): string | null => {
14
+ try {
15
+ return readFileSync(orchestrateSkillPath(packageRoot), 'utf8')
16
+ } catch {
17
+ return null
18
+ }
19
+ }
20
+
21
+ /**
22
+ * 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.
25
+ */
26
+ export const skillChangeSince = (snapshot: string | null, packageRoot: string, directory: string): SkillChange => {
27
+ if (snapshot === null) return { kind: SKILL_CHANGE.unavailable, reason: 'no snapshot at start' }
28
+ const current = readOrchestrateSkill(packageRoot)
29
+ if (current === null) return { kind: SKILL_CHANGE.unavailable, reason: 'skill not readable' }
30
+ if (current === snapshot) return { kind: SKILL_CHANGE.unchanged }
31
+ 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 }
50
+ } catch (error) {
51
+ return { kind: SKILL_CHANGE.unavailable, reason: errorText(error) }
52
+ }
53
+ }
54
+
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
+ )
@@ -0,0 +1,35 @@
1
+ import { readResolvedConfig } from '../config/read-config'
2
+ import { type JackherdConfig, parseSourceDraft } from '../config/schema'
3
+ import { mainCheckout, worktreeRoot } from '../plans/git'
4
+ import type { SourceDryRun } from './options'
5
+ import { type DryRunTarget, dryRunExitCode, dryRunLines, dryRunTarget, emptyMapFields } from './source-dry-run'
6
+ import { runSourceCommand, SOURCE_LIMITS, sourceRunOf } from './source-run'
7
+ import type { SourceSettings } from './sources'
8
+
9
+ const settingsOf = async (target: DryRunTarget, config: JackherdConfig): Promise<SourceSettings> => {
10
+ if (target.kind === 'name') {
11
+ const found = config.watch.sources.find(({ name }) => name === target.name)
12
+ if (!found) throw new Error(`no source ${target.name} in watch.sources`)
13
+ return found
14
+ }
15
+ const text = target.kind === 'stdin' ? await Bun.stdin.text() : await Bun.file(target.path).text()
16
+ return parseSourceDraft(JSON.parse(text), target.kind === 'stdin' ? 'stdin' : target.path)
17
+ }
18
+
19
+ /**
20
+ * `jh watch source <name|draft.json|-> --dry-run [--raw]`: runs one source once in the main checkout, as the
21
+ * watcher would, and prints what it found and would report; `--raw` adds the command's output. Takes no lock and
22
+ * neither reads nor writes the watcher's state, so a running watcher is not disturbed. Exits 1 when the run failed or a
23
+ * `map` field is empty in every entry.
24
+ */
25
+ export const sourceDryRun = async ({ target, raw }: SourceDryRun): Promise<number> => {
26
+ const root = mainCheckout(process.cwd())
27
+ const { config } = readResolvedConfig([worktreeRoot(process.cwd()), root])
28
+ const settings = await settingsOf(dryRunTarget(target), config)
29
+ const answer = await runSourceCommand(settings.command, root, SOURCE_LIMITS)
30
+ const run = sourceRunOf(settings, answer)
31
+ const mapCheck = answer.ok ? emptyMapFields(answer.stdout, settings) : null
32
+ const stdout = raw && answer.ok ? answer.stdout : undefined
33
+ console.log(dryRunLines(settings, run, { mapCheck, stdout }).join('\n'))
34
+ return dryRunExitCode(run, mapCheck)
35
+ }
@@ -0,0 +1,124 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { sourceEventLines } from './lane-report'
3
+ import {
4
+ isIgnoredAuthor,
5
+ listAt,
6
+ type SourceItem,
7
+ type SourceRun,
8
+ type SourceSettings,
9
+ sourceEvents,
10
+ textAt,
11
+ } from './sources'
12
+
13
+ /** What `watch source <target> --dry-run` names: a source of the config, a draft file holding one source, or a draft on stdin. */
14
+ export type DryRunTarget = { kind: 'name'; name: string } | { kind: 'file'; path: string } | { kind: 'stdin' }
15
+
16
+ /** The map fields empty in every one of `total` raw entries, each with its path. */
17
+ export type MapCheck = { total: number; empty: { field: string; path: string }[] }
18
+
19
+ /** What one dry run shows besides the run: the map check (null when the output has no list) and the raw output. */
20
+ export type DryRunExtras = { mapCheck: MapCheck | null; stdout?: string }
21
+
22
+ const STDIN = '-'
23
+ const LISTED_ENTRIES = 10
24
+ const ENTRY_LENGTH = 120
25
+ const RAW_LINES = 40
26
+ const RAW_LINE_LENGTH = 200
27
+ const INDENT = ' '
28
+ const BASELINE = 'first run of this source: baseline, nothing reported'
29
+ const NOT_CHECKED = '0 entries: map not checked'
30
+ const FOOTER = 'watcher state not read or changed; the watcher takes a new source over at its next start'
31
+
32
+ export const dryRunTarget = (value: string): DryRunTarget => {
33
+ if (value === STDIN) return { kind: 'stdin' }
34
+ return value.includes('/') || value.endsWith('.json') ? { kind: 'file', path: value } : { kind: 'name', name: value }
35
+ }
36
+
37
+ const isEmptyAt = (entry: unknown, path: string): boolean => {
38
+ const text = textAt(entry, path)
39
+ return text === null || text === ''
40
+ }
41
+
42
+ /**
43
+ * Each `map` field that is empty (missing, null, no text or number, or `''`) in every entry of the list at `items`,
44
+ * read from the raw output, so a run that failed on a missing `id` or `updatedAt` still names it; null when the
45
+ * output has no list there.
46
+ */
47
+ export const emptyMapFields = (stdout: string, settings: Pick<SourceSettings, 'items' | 'map'>): MapCheck | null => {
48
+ const raw = listAt(stdout, settings.items)
49
+ if (!raw.ok) return null
50
+ const { list } = raw
51
+ const fields = Object.entries(settings.map).flatMap(([field, path]) => (path === undefined ? [] : [{ field, path }]))
52
+ const empty = list.length === 0 ? [] : fields.filter(({ path }) => list.every((entry) => isEmptyAt(entry, path)))
53
+ return { total: list.length, empty }
54
+ }
55
+
56
+ /** 1 when the run failed or a map field is empty in every entry, so `/follow` does not write the source; else 0. */
57
+ export const dryRunExitCode = (run: SourceRun, mapCheck: MapCheck | null): number =>
58
+ run.result.ok && (mapCheck?.empty.length ?? 0) === 0 ? 0 : 1
59
+
60
+ /** `map.<field>: in <n> of <n> entries empty (<path>)` per empty field; for no entries, that nothing was checked. */
61
+ const mapCheckLines = (mapCheck: MapCheck | null): string[] => {
62
+ if (!mapCheck) return []
63
+ if (mapCheck.total === 0) return [NOT_CHECKED]
64
+ const { total } = mapCheck
65
+ return mapCheck.empty.map(
66
+ ({ field, path }) => `map.${terminalSafe(field)}: in ${total} of ${total} entries empty (${terminalSafe(path)})`,
67
+ )
68
+ }
69
+
70
+ /** `<id> · <author> · <title>`, marked when `ignoreAuthors` holds it back. */
71
+ const entryLine = (item: SourceItem, settings: SourceSettings): string => {
72
+ const parts = [item.id, item.author ?? '', item.title].filter((part) => part !== '')
73
+ const ignored = isIgnoredAuthor(item, settings.ignoreAuthors) ? ' (ignoreAuthors)' : ''
74
+ return `${INDENT}${terminalSafe(parts.join(' · ')).slice(0, ENTRY_LENGTH)}${ignored}`
75
+ }
76
+
77
+ const entryLines = (items: readonly SourceItem[], settings: SourceSettings): string[] => {
78
+ const more = items.length - LISTED_ENTRIES
79
+ return [
80
+ `${items.length} entries`,
81
+ ...items.slice(0, LISTED_ENTRIES).map((item) => entryLine(item, settings)),
82
+ ...(more > 0 ? [`${INDENT}… ${more} more`] : []),
83
+ ]
84
+ }
85
+
86
+ const cutLine = (line: string): string =>
87
+ line.length > RAW_LINE_LENGTH
88
+ ? `${line.slice(0, RAW_LINE_LENGTH)} … +${line.length - RAW_LINE_LENGTH} characters`
89
+ : line
90
+
91
+ /** The command's output line by line, cut to `RAW_LINES` of `RAW_LINE_LENGTH` characters, for finding the paths of `items` and `map`. */
92
+ const rawLines = (stdout: string): string[] => {
93
+ const lines = stdout.replace(/\n$/, '').split('\n')
94
+ const more = lines.length - RAW_LINES
95
+ return [
96
+ 'output:',
97
+ ...lines.slice(0, RAW_LINES).map((line) => `${INDENT}${cutLine(terminalSafe(line))}`),
98
+ ...(more > 0 ? [`${INDENT}… ${more} more lines`] : []),
99
+ ]
100
+ }
101
+
102
+ /** The events of the run against a source whose baseline holds nothing: every entry not held back as `new`. */
103
+ const eventsOf = (settings: SourceSettings, run: SourceRun) =>
104
+ sourceEvents({ [settings.name]: { seen: [], failingSince: null } }, [run], [settings])
105
+
106
+ /** The watcher's first run of a source reports nothing; one entry shows how a later new or changed one would read. */
107
+ const exampleLines = (settings: SourceSettings, run: SourceRun): string[] => {
108
+ const example = eventsOf(settings, run).find((event) => 'id' in event)
109
+ if (!example) return [`${BASELINE}; no entry here would report`]
110
+ return [`${BASELINE}; later a new or changed entry reports like this:`, ...sourceEventLines(example)]
111
+ }
112
+
113
+ /**
114
+ * What one dry run of a source shows: its command, `stdout` when asked for (`--raw`), the entries after `map` as a short
115
+ * list (`<id> · <author> · <title>`), that the watcher's first run of the source is a baseline and reports nothing, and
116
+ * one entry as a later report would show it; a failure as the watcher would report it. The `map` fields empty in every
117
+ * entry come after the raw output. Nothing of the watcher's state is read or written.
118
+ */
119
+ export const dryRunLines = (settings: SourceSettings, run: SourceRun, extras: DryRunExtras): string[] => {
120
+ const head = `dry run source ${terminalSafe(settings.name)}: ${terminalSafe(JSON.stringify(settings.command))}`
121
+ const top = [head, ...(extras.stdout === undefined ? [] : rawLines(extras.stdout)), ...mapCheckLines(extras.mapCheck)]
122
+ if (!run.result.ok) return [...top, ...eventsOf(settings, run).flatMap(sourceEventLines), FOOTER]
123
+ return [...top, ...entryLines(run.result.items, settings), ...exampleLines(settings, run), FOOTER]
124
+ }
@@ -0,0 +1,9 @@
1
+ /** The fields of `map` the watcher knows by name; any further field is only read for `reportOn`. */
2
+ export const SOURCE_FIELD = {
3
+ id: 'id',
4
+ updatedAt: 'updatedAt',
5
+ title: 'title',
6
+ url: 'url',
7
+ body: 'body',
8
+ author: 'author',
9
+ } as const
@@ -0,0 +1,40 @@
1
+ import { isRecord } from './json-guards'
2
+
3
+ /**
4
+ * Where a field sits in a source's JSON output: `.` is the whole output, else a chain of `.key`, `[n]` and `[-n]`
5
+ * (counted from the end), such as `.fields.comment.comments[-1].body`. No jq: nothing else is understood.
6
+ */
7
+ export type SourcePath = string
8
+
9
+ export const ROOT_PATH = '.'
10
+
11
+ const PATH_STEP = /\.([^\s.[\]|]+)|\[(-?\d+)\]/y
12
+
13
+ type PathStep = { key: string } | { index: number }
14
+
15
+ /** The steps of `path`, or null when it is no source path. */
16
+ const pathSteps = (path: string): PathStep[] | null => {
17
+ if (path === ROOT_PATH) return []
18
+ const steps: PathStep[] = []
19
+ PATH_STEP.lastIndex = 0
20
+ while (PATH_STEP.lastIndex < path.length) {
21
+ const match = PATH_STEP.exec(path)
22
+ if (!match) return null
23
+ steps.push(match[1] === undefined ? { index: Number(match[2]) } : { key: match[1] })
24
+ }
25
+ return steps.length > 0 ? steps : null
26
+ }
27
+
28
+ export const isSourcePath = (path: string): boolean => pathSteps(path) !== null
29
+
30
+ const stepInto = (value: unknown, step: PathStep): unknown => {
31
+ if ('key' in step) return isRecord(value) && Object.hasOwn(value, step.key) ? value[step.key] : undefined
32
+ return Array.isArray(value) ? value.at(step.index) : undefined
33
+ }
34
+
35
+ /** The value at `path` in `value`; undefined when a step does not fit or the path is invalid. */
36
+ export const selectPath = (value: unknown, path: SourcePath): unknown =>
37
+ (pathSteps(path) ?? [undefined]).reduce<unknown>(
38
+ (current, step) => (step === undefined ? undefined : stepInto(current, step)),
39
+ value,
40
+ )
@@ -0,0 +1,90 @@
1
+ import { exitFailure, parseSourceOutput, SOURCE_FAILURE, type SourceRun, type SourceSettings } from './sources'
2
+
3
+ /** How long a source's command may run and how much it may print before it is stopped. */
4
+ export type SourceLimits = { timeoutMs: number; maxBytes: number }
5
+
6
+ export type SourceAnswer = { ok: true; stdout: string } | { ok: false; reason: string }
7
+
8
+ export const SOURCE_LIMITS: SourceLimits = { timeoutMs: 30_000, maxBytes: 1_000_000 }
9
+
10
+ type Child = ReturnType<typeof Bun.spawn<'ignore', 'pipe', 'ignore'>>
11
+
12
+ const TIMED_OUT = Symbol('timed out')
13
+ const TIMED_OUT_ANSWER: SourceAnswer = { ok: false, reason: SOURCE_FAILURE.timeout }
14
+ const TOO_LARGE_ANSWER: SourceAnswer = { ok: false, reason: SOURCE_FAILURE.tooLarge }
15
+ const UNREADABLE_ANSWER: SourceAnswer = { ok: false, reason: SOURCE_FAILURE.unreadable }
16
+ const READ_FAILED = Symbol('read failed')
17
+
18
+ const answerOf = (stdout: string, exitCode: number): SourceAnswer =>
19
+ exitCode === 0 ? { ok: true, stdout } : { ok: false, reason: exitFailure(exitCode) }
20
+
21
+ /** The run a command's answer makes: its output over `items` and `map`, or the reason it failed. */
22
+ export const sourceRunOf = (settings: SourceSettings, answer: SourceAnswer): SourceRun => ({
23
+ name: settings.name,
24
+ result: answer.ok ? parseSourceOutput(answer.stdout, settings) : answer,
25
+ })
26
+
27
+ /** Reads stdout up to `maxBytes`; null once it went past them. */
28
+ const readCapped = async (child: Child, maxBytes: number): Promise<string | null> => {
29
+ const chunks: Uint8Array[] = []
30
+ let size = 0
31
+ for await (const chunk of child.stdout) {
32
+ size += chunk.byteLength
33
+ if (size > maxBytes) return null
34
+ chunks.push(chunk)
35
+ }
36
+ return Buffer.concat(chunks).toString('utf8')
37
+ }
38
+
39
+ /** Started in a process group of its own, so a timeout reaches what the command started too (`curl` behind `sh`). */
40
+ const spawned = (command: readonly string[], cwd: string): Child | null => {
41
+ try {
42
+ return Bun.spawn([...command], { cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'ignore', detached: true })
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /** Kills the command's whole process group, best effort: a group that ended (ESRCH) or cannot be signalled is left alone. */
49
+ const killGroup = (child: Child) => {
50
+ try {
51
+ process.kill(-child.pid, 'SIGKILL')
52
+ } catch {
53
+ // Nothing left to kill, or nothing this process may kill; the run's answer stands either way.
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Runs `command` as given, without a shell, in `cwd` with the user's environment; never rejects. stderr is not read,
59
+ * so nothing a failing tool prints there (a token, a URL with credentials) reaches the report. Past `timeoutMs` or
60
+ * `maxBytes` the command's process group is killed, and the answer never waits past `timeoutMs` for a pipe a leftover
61
+ * process keeps open; the group is also killed after a normal end, so a source leaves no process behind that stays in its group.
62
+ */
63
+ export const runSourceCommand = async (
64
+ command: readonly string[],
65
+ cwd: string,
66
+ limits: SourceLimits,
67
+ ): Promise<SourceAnswer> => {
68
+ const child = spawned(command, cwd)
69
+ if (!child) return { ok: false, reason: SOURCE_FAILURE.notStarted }
70
+ let timer: ReturnType<typeof setTimeout> | undefined
71
+ const deadline = new Promise<typeof TIMED_OUT>((resolve) => {
72
+ timer = setTimeout(() => resolve(TIMED_OUT), limits.timeoutMs)
73
+ })
74
+ try {
75
+ const reading = readCapped(child, limits.maxBytes).catch((): typeof READ_FAILED => READ_FAILED)
76
+ const stdout = await Promise.race([reading, deadline])
77
+ if (stdout === TIMED_OUT) return TIMED_OUT_ANSWER
78
+ if (stdout === null) return TOO_LARGE_ANSWER
79
+ if (stdout === READ_FAILED) return UNREADABLE_ANSWER
80
+ const exitCode = await Promise.race([child.exited, deadline])
81
+ return exitCode === TIMED_OUT ? TIMED_OUT_ANSWER : answerOf(stdout, exitCode)
82
+ } finally {
83
+ clearTimeout(timer)
84
+ killGroup(child)
85
+ }
86
+ }
87
+
88
+ /** One run of a source: its command, then its output over `items` and `map`. */
89
+ export const runSource = async (settings: SourceSettings, cwd: string, limits: SourceLimits): Promise<SourceRun> =>
90
+ sourceRunOf(settings, await runSourceCommand(settings.command, cwd, limits))