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
@@ -3,13 +3,13 @@ import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from '.
3
3
  import { shownWithMore } from './shown-with-more'
4
4
  import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
5
5
 
6
- /** The words of an open lane: its section labels, the age of a running phase, the wave, the finished phases not merged yet, and the pointer of a phase with only its review gate open. */
6
+ /** The words of an open lane: the age of a running phase, the words before the open phases that wait and the finished ones, and the pointer of a phase with only its review gate open. */
7
7
  export type HerdTexts = {
8
- sections: { session: string; chips: string; now: string; next: string; later: string; done: string }
9
8
  minutesAgo: (minutes: number) => string
10
- wave: (wave: number) => string
11
- notMerged: string
9
+ next: string
10
+ done: string
12
11
  reviewGate: string
12
+ gate: string
13
13
  }
14
14
 
15
15
  /**
@@ -23,7 +23,14 @@ export type PlanTexts = {
23
23
  noPlan: string
24
24
  noWorktree: string
25
25
  noRow: string
26
+ /** The heads of the blocks on top of the herd. */
27
+ waitingHeading: string
28
+ appointmentHeading: string
26
29
  notInPlan: string
30
+ /** Before a task an agent reports that no task of the plan names. */
31
+ now: string
32
+ /** Before the description of a running subagent that names no phase and sits on none. */
33
+ subagent: string
27
34
  /** In place of the count of a phase whose only open box is the documentation box of its review gate. */
28
35
  allButDocs: string
29
36
  tickedSummary: (count: number) => string
@@ -34,7 +41,13 @@ export type PlanTexts = {
34
41
  herd: HerdTexts
35
42
  checks: Record<ChecksState, string>
36
43
  hint: string
44
+ /** The notice when `a` is pressed with no phase open, where the key changes nothing on screen. */
45
+ showDoneNeedsPhase: string
37
46
  legend: string[]
47
+ /** The footer line when the coordinator could not be asked to put a question, with the reason. */
48
+ notAsked: (reason: string) => string
49
+ /** The footer line when the coordinator `name` was asked to put `subject`, `question <id>` or `board row <key>`. */
50
+ asked: (name: string, subject: string) => string
38
51
  }
39
52
 
40
53
  /** The words of the watcher's badges and of the header notice when they are missing. */
@@ -64,6 +77,7 @@ export const boardTexts = (term: string) => ({
64
77
  pageNotWritten: (reason: string) => `${term} page not written: ${reason}`,
65
78
  pageNotOpenedReason: (reason: string) => `${term} page: ${reason}`,
66
79
  notReadable: (reason: string) => `${term} not readable: ${reason}`,
80
+ notOpened: (reason: string) => `could not open: ${reason}`,
67
81
  })
68
82
 
69
83
  const ALL_BUT_DOCS = 'docs open'
@@ -75,18 +89,22 @@ export const TEXTS: PlanTexts = {
75
89
  noPlan: 'no plan',
76
90
  noWorktree: 'no worktree',
77
91
  noRow: 'no row in lanes',
92
+ waitingHeading: 'Waiting for you',
93
+ appointmentHeading: 'Appointments',
78
94
  notInPlan: 'not in the plan',
95
+ now: 'now:',
96
+ subagent: 'Subagent:',
79
97
  allButDocs: ALL_BUT_DOCS,
80
98
  tickedSummary: (count) => `${count} ticked · click or a lists them`,
81
99
  draft: 'draft · ',
82
100
  privateLink: 'private',
83
101
  phaseTally: (done, total) => (total > 0 ? `${done}/${total} ph` : ''),
84
102
  herd: {
85
- sections: { session: 'SESSION', chips: '📎', now: 'NOW', next: 'NEXT', later: 'LATER', done: 'DONE' },
86
103
  minutesAgo: (minutes) => (minutes === 0 ? '· just now' : `· ${minutes} min ago`),
87
- wave: (wave) => `wave ${wave}:`,
88
- notMerged: 'not merged',
104
+ next: 'next',
105
+ done: 'done',
89
106
  reviewGate: 'Review gate',
107
+ gate: '· gate',
90
108
  },
91
109
  planned: (phases, plans) =>
92
110
  `planned (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
@@ -96,15 +114,19 @@ export const TEXTS: PlanTexts = {
96
114
  [CHECKS.pending]: '… checks',
97
115
  [CHECKS.none]: 'no checks',
98
116
  },
99
- hint: '↑↓ select →← open a show ticked s html q quit ? help',
117
+ hint: '↑↓ select →← open a ticked tasks s html q quit ? help',
118
+ showDoneNeedsPhase: 'a lists the ticked tasks inside an open phase: open one with → or a click',
100
119
  legend: [
101
- 'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a 📎 chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
102
- '● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · working and blocked lanes open by themselves, → ← overrule until the status changes',
103
- `▸ ▾ open and close · NOW ▶ the phases whose boxes or task lines moved in the last ${STALE_ACTIVITY_MINUTES} min, the one moved last first, · n min ago since then, → the task each is on (Review gate = only its review gate is open) · NEXT the next wave · LATER the other open phases · DONE ✓ the finished ones, not merged = ticked in the worktree, not on the base branch yet · ${ALL_BUT_DOCS} = only the documentation box is open · the lane line names the phase moved last · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes one by one · s opens every lane and plan as one HTML page`,
104
- `◐ progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
105
- '↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status under 📎, 📡 <n> in amber on a folded lane when tickets are new',
106
- "SESSION: the agent's title · ⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main",
120
+ 'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a link chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
121
+ '● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · • state unknown · only blocked wears its word in the lane line, the symbol says the rest · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · a working or blocked lane opens by itself only when it first appears, after that → ← and clicks are yours alone',
122
+ `▸ ▾ open and close · an open lane's branches, top down: the agent's title and ⎇ its branch, ↗ its pull requests, its link chips, the phases it works on, next, done · a lane shows only the phases its board rows name · a phase row = some of its tasks are ticked, one of its boxes was ticked in the last ${STALE_ACTIVITY_MINUTES} min, or an agent reports working on it, by number · n min ago since the last tick or report, · gate = only the review gate is open · ▶ teal = a task an agent reported (its task list or jh task; its name dim at the right edge when several agents work in the lane), ▷ dim = only guessed from the words of its log · now: <text> = a reported task no plan task names · a running review sits on the gate box that names it, in the phase it names or else the lane's current one · Subagent: <text> = a running subagent that names no phase and sits on none · next = the other open phases, as ranges · done = the finished ones · ${ALL_BUT_DOCS} = only the documentation box is open · a folded lane line names the phase moved last, +n more active ones, and its count · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes of an open phase one by one · s opens every lane and plan as one HTML page`,
123
+ `the bar = progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used, on a folded lane only once it warns · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
124
+ '↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status among the link chips, 📡 <n> in amber on a folded lane when tickets are new',
125
+ '▌ on top = waits for your answer: 📎 a file or an Artifact that was sent (click: a cut-off row expands, then it opens), ? a question of the board or one a session left in its dialog (a click on its text only expands and folds it; [ask] at the right edge of a question asks the coordinator to put it) · it goes when the board row is closed or `jh waiting close <id>` · `Waiting for you` heads the block (click: fold; click on +n: show all, on ▴ n: five) · ◷ the appointments of the board in a grey bar below it (click: fold)',
126
+ '⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main',
107
127
  ],
128
+ notAsked: (reason) => `no coordinator to ask: ${reason}`,
129
+ asked: (name, subject) => `asked ${name} for ${subject}`,
108
130
  }
109
131
 
110
132
  /** The mark of a running lane the board has no row for; null for a lane it lists. */
@@ -2,8 +2,8 @@ import { isAbsolute, relative, sep } from 'node:path'
2
2
  import type { SprintLane } from '../plan-format'
3
3
  import type { LaneAgent } from '../terminal'
4
4
 
5
- /** A worktree with an agent that has no row in the sprint table; `name` is its directory below the worktree directory. */
6
- export type UnlistedLane = { name: string; agent: LaneAgent }
5
+ /** A worktree with an agent, or only a subagent of the coordinator, that has no row in the sprint table; `name` is its directory below the worktree directory. */
6
+ export type UnlistedLane = { name: string; agent: LaneAgent | null }
7
7
 
8
8
  type PlacedAgent = { lane: string; agent: LaneAgent }
9
9
 
@@ -25,21 +25,26 @@ const placed = (agents: readonly LaneAgent[], worktreeDirectory: string): Placed
25
25
 
26
26
  /**
27
27
  * The lanes that run but are missing from the sprint table: one per worktree below `worktreeDirectory` with an
28
- * agent in it. A worktree counts as listed when the table names it, or names any agent running in it. The first
29
- * agent of a worktree stands for it; the lanes come in name order.
28
+ * agent in it, or a subagent of the coordinator at work in it (`subagentLanes`, no agent then). A worktree counts as
29
+ * listed when the table names it, or names any agent running in it. The first agent of a worktree stands for it; the
30
+ * lanes come in name order.
30
31
  */
31
32
  export const unlistedLanes = (
32
33
  agents: readonly LaneAgent[],
33
34
  listed: ReadonlySet<string>,
34
35
  worktreeDirectory: string,
36
+ subagentLanes: ReadonlySet<string> = new Set(),
35
37
  ): UnlistedLane[] => {
36
38
  const inWorktrees = placed(agents, worktreeDirectory)
37
39
  const listedWorktrees = new Set(
38
40
  inWorktrees.filter(({ lane, agent }) => listed.has(lane) || listed.has(agent.name)).map(({ lane }) => lane),
39
41
  )
40
- const byLane = new Map<string, LaneAgent>()
42
+ const byLane = new Map<string, LaneAgent | null>()
41
43
  for (const { lane, agent } of inWorktrees) {
42
44
  if (!listedWorktrees.has(lane) && !byLane.has(lane)) byLane.set(lane, agent)
43
45
  }
46
+ for (const lane of subagentLanes) {
47
+ if (!listed.has(lane) && !byLane.has(lane)) byLane.set(lane, null)
48
+ }
44
49
  return [...byLane].sort(([left], [right]) => left.localeCompare(right)).map(([name, agent]) => ({ name, agent }))
45
50
  }
package/src/plans/view.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ARTIFACT_LINK_KIND } from '../config/link-kinds'
1
2
  import { fit, plainText } from '../terminal/text'
2
3
  import { renderSelected, type Screen } from './layout'
3
4
  import type { Options } from './options'
@@ -20,7 +21,9 @@ const paneRows = (): number => process.stdout.rows ?? Number.MAX_SAFE_INTEGER
20
21
  export const bodyRows = (screen: Screen): number =>
21
22
  Math.max(paneRows() - screen.header.length - screen.footer.length, 1)
22
23
 
23
- export const clockOf = (locale: string): string => new Date().toLocaleTimeString(locale)
24
+ /** The time as `HH:MM`, in the header and in the refresh error alike: a frame differs only once a minute. */
25
+ export const clockOf = (locale: string, now: Date = new Date()): string =>
26
+ now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
24
27
 
25
28
  export const renderSnapshot = (
26
29
  snapshot: Snapshot,
@@ -37,6 +40,9 @@ export const renderSnapshot = (
37
40
  planned: snapshot.planned,
38
41
  watch: snapshot.watch,
39
42
  watchesTickets: snapshot.watchesTickets,
43
+ awaiting: snapshot.waiting,
44
+ appointments: snapshot.appointments,
45
+ unplaced: snapshot.unplaced,
40
46
  },
41
47
  selection,
42
48
  width,
@@ -45,24 +51,26 @@ export const renderSnapshot = (
45
51
  }
46
52
 
47
53
  export const startSelection = (snapshot: Snapshot, options: Options): Selection =>
48
- options.lane
49
- ? laneSelection(snapshot.views[0]?.progress, snapshot.views[0]?.status.agent ?? null)
50
- : overviewSelection()
54
+ options.lane ? laneSelection(snapshot.views[0]?.progress) : overviewSelection()
51
55
 
52
56
  /** Header and footer stay put; the body shows `bodyRows` lines from `top`, padded so the footer sits at the bottom. */
53
- export const draw = (screen: Screen, top: number): void => {
54
- const width = paneWidth()
55
- const rows = bodyRows(screen)
57
+ export const frameOutput = (screen: Screen, top: number, width: number, rows: number): string => {
56
58
  const body = screen.body.lines.slice(top, top + rows)
57
59
  const padding = Array.from({ length: Math.max(rows - body.length, 0) }, () => '')
58
60
  const lines = [...screen.header, ...body, ...padding, ...screen.footer]
59
- process.stdout.write(
60
- `${CURSOR_HOME}${lines.map((line) => `${fit(line, width)}${CLEAR_LINE_END}`).join('\r\n')}${CLEAR_BELOW}`,
61
- )
61
+ return `${CURSOR_HOME}${lines.map((line) => `${fit(line, width)}${CLEAR_LINE_END}`).join('\r\n')}${CLEAR_BELOW}`
62
62
  }
63
63
 
64
- /** One frame as text; ANSI styling is dropped when stdout is not a terminal. */
64
+ /** The frame to write, or null when the terminal already shows it; a null `previous` means the terminal content is unknown. */
65
+ export const frameToWrite = (previous: string | null, next: string): string | null => (previous === next ? null : next)
66
+
67
+ const ARTIFACT_ADDRESS = new RegExp(ARTIFACT_LINK_KIND.pattern)
68
+
69
+ /**
70
+ * One frame as text; ANSI styling is dropped when stdout is not a terminal, and links are written out as their
71
+ * address, except an Artifact's, which is long and would push the rest of its line past the pane.
72
+ */
65
73
  export const frameText = (screen: Screen, isTerminal: boolean): string => {
66
74
  const lines = [...screen.header, ...screen.body.lines, ...screen.footer]
67
- return (isTerminal ? lines : lines.map(plainText)).join('\n')
75
+ return (isTerminal ? lines : lines.map((line) => plainText(line, (url) => !ARTIFACT_ADDRESS.test(url)))).join('\n')
68
76
  }
@@ -0,0 +1,231 @@
1
+ import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
2
+ import { fit, padVisible, wrap } from '../terminal/text'
3
+ import { textWidth } from '../terminal/width'
4
+ import { ASK_KIND, type AskTarget } from './ask-target'
5
+ import { plain } from './layout-phase'
6
+ import { MOUSE_BUTTON, type MouseEvent } from './selection'
7
+ import { shortTitle } from './task-name'
8
+
9
+ /**
10
+ * One row of a block of the herd as drawn: its plain text lines (more than one while it is expanded; the first starts
11
+ * with `mark` and a blank and, for a row with `[ask]`, is padded to leave the button its cells), whether its text is wider than the pane (`cut`), whether it is expanded, and the address a
12
+ * click opens.
13
+ */
14
+ export type BlockRow = {
15
+ key: string
16
+ mark: string
17
+ lines: string[]
18
+ cut: boolean
19
+ expanded: boolean
20
+ url: string | null
21
+ /** What the coordinator can be asked to put: a dialog question or a row of the board; null for files and appointments. */
22
+ ask: AskTarget | null
23
+ /** The screen column (1-based) `[ask]` starts in on the first line, five cells wide; null for a row without one. */
24
+ askColumn: number | null
25
+ /** What the folded block shows of the row: the time column, if any, and the short title, without the mark. */
26
+ preview: string
27
+ /** Per line, the index in it where the part behind the short title starts; the line's length where none of it is. */
28
+ dimFrom: number[]
29
+ }
30
+
31
+ /** What a click does: expand or fold the row (its key), open an address, ask the coordinator to put the row, or nothing (null). */
32
+ export type BlockClick = { toggle: string } | { open: string } | { ask: AskTarget }
33
+
34
+ /**
35
+ * One row of a block of the herd before it is drawn: the key it expands by, the `mark` that starts it (kept apart so
36
+ * the block can paint it), its text, and what a click may do.
37
+ */
38
+ export type BlockItem = {
39
+ key: string
40
+ mark: string
41
+ /** A column before the text, padded by the caller to the widest of the block (the time of an appointment). */
42
+ lead?: string
43
+ text: string
44
+ /** What the row shows while folded; the whole `text` when absent. */
45
+ title?: string
46
+ url: string | null
47
+ ask: AskTarget | null
48
+ }
49
+
50
+ /**
51
+ * What a click on a block is matched against, built from the block as it was drawn: `firstLine` is the screen line
52
+ * (0-based) of the first row, the line above it is the block's head; `rows` and `moreKey` are empty and null while the
53
+ * block is folded; `keys` are all its points, what a fold of the block remembers.
54
+ */
55
+ export type BlockHit = {
56
+ firstLine: number
57
+ rows: readonly BlockRow[]
58
+ moreKey: string | null
59
+ keys: readonly string[]
60
+ }
61
+
62
+ /** How many rows a block of the herd (for-you, appointments) draws; the rest is one `+<n>` line. */
63
+ export const SHOWN_BLOCK_ROWS = 5
64
+ /** The key in the herd's expanded set that shows every point of the for-you block instead of the first five. */
65
+ export const WAITING_MORE_KEY = 'more>waiting'
66
+ /** The same for the appointment block. */
67
+ export const APPOINTMENT_MORE_KEY = 'more>appointment'
68
+ /** The bar and the blank that start every line of a for-you row. */
69
+ export const WAITING_MARK = '▌'
70
+
71
+ /** The button at the right edge of a question or a board row; a click on it, and only on it, asks the coordinator. */
72
+ export const ASK_BUTTON = '[ask]'
73
+ const ASK_GAP = 1
74
+ const FILE_MARK = '📎'
75
+ const QUESTION_MARK = '?'
76
+ const OPENABLE = /^(https?|file):\/\//
77
+
78
+ /** The key of a row in the herd's expanded set: the record's id, else its text. */
79
+ export const waitingKey = ({ id, item }: WaitingItem): string => `waiting>${id ?? item}`
80
+
81
+ /** The keys of all open points, the shown ones and the `+<n>` rest alike: what the fold of the block remembers. */
82
+ export const waitingKeys = (items: readonly WaitingItem[]): string[] => items.map(waitingKey)
83
+
84
+ const BAR_WIDTH = textWidth(`${WAITING_MARK} `)
85
+
86
+ const roomOf = (width: number): number => Math.max(width - BAR_WIDTH, 1)
87
+
88
+ /** The cells a row's text may take and the screen column `[ask]` starts in; no column for a row without a button. */
89
+ const askLayout = (ask: AskTarget | null, width: number): { room: number; askColumn: number | null } => {
90
+ if (!ask) return { room: roomOf(width), askColumn: null }
91
+ const room = Math.max(roomOf(width) - (textWidth(ASK_BUTTON) + ASK_GAP), 1)
92
+ return { room, askColumn: BAR_WIDTH + room + ASK_GAP + 1 }
93
+ }
94
+
95
+ const askOf = (item: WaitingItem): AskTarget | null => {
96
+ if (item.kind === WAITING_KIND.file) return null
97
+ if (item.kind === WAITING_KIND.question) return item.id ? { kind: ASK_KIND.question, id: item.id } : null
98
+ return { kind: ASK_KIND.boardRow, since: item.since, item: item.item }
99
+ }
100
+
101
+ /** The text wrapped to `room` cells behind `start` (the mark and the time column), the other lines indented to the column of the text. */
102
+ const hangingLines = (start: string, text: string, room: number): string[] => {
103
+ const hang = textWidth(start)
104
+ const [first, ...rest] = wrap(text, Math.max(room - hang, 1))
105
+ return [`${start}${first}`, ...rest.map((line) => `${' '.repeat(hang)}${line}`)]
106
+ }
107
+
108
+ /**
109
+ * Per line, the index where the part behind the short title starts (the line's length while none of it is on the
110
+ * line): the title may take several lines, and the rest starts on the line where it ends. `titleLength` is 0 for a
111
+ * row without a short title.
112
+ */
113
+ const dimStarts = (lines: readonly string[], start: string, titleLength: number): number[] => {
114
+ let remaining = titleLength
115
+ return lines.map((line, index) => {
116
+ if (titleLength === 0) return line.length
117
+ const from = index === 0 ? start.length : textWidth(start)
118
+ const content = line.length - from
119
+ if (remaining > content) {
120
+ remaining -= content + 1
121
+ return line.length
122
+ }
123
+ const dimFrom = from + Math.max(remaining, 0)
124
+ remaining = 0
125
+ return dimFrom
126
+ })
127
+ }
128
+
129
+ const itemOf = (item: WaitingItem): BlockItem => ({
130
+ key: waitingKey(item),
131
+ mark: item.kind === WAITING_KIND.file ? FILE_MARK : QUESTION_MARK,
132
+ text: plain(item.item),
133
+ title: shortTitle(plain(item.item)),
134
+ url: item.kind === WAITING_KIND.file && item.url && OPENABLE.test(item.url) ? item.url : null,
135
+ ask: askOf(item),
136
+ })
137
+
138
+ const rowOf = (
139
+ { key, mark, lead, text, title = text, url, ask }: BlockItem,
140
+ expandedKeys: ReadonlySet<string>,
141
+ width: number,
142
+ ): BlockRow => {
143
+ const { room, askColumn } = askLayout(ask, width)
144
+ const leadText = lead === undefined ? '' : `${lead} `
145
+ const start = `${mark} ${leadText}`
146
+ const cut = title !== text || textWidth(`${start}${text}`) > room
147
+ const expanded = cut && expandedKeys.has(key)
148
+ const [first, ...rest] = expanded ? hangingLines(start, text, room) : [fit(`${start}${title}`, room)]
149
+ const lines = ask ? [padVisible(first, room + ASK_GAP), ...rest] : [first, ...rest]
150
+ const dimFrom = dimStarts([first, ...rest], start, expanded && title !== text ? title.length : 0)
151
+ return { key, mark, lines, cut, expanded, url, ask, askColumn, preview: `${leadText}${title}`, dimFrom }
152
+ }
153
+
154
+ /**
155
+ * The rows a block of the herd draws: the first five, or all of them while `moreKey` is in the expanded set; each
156
+ * folded to one line unless expanded.
157
+ */
158
+ export const blockRows = (
159
+ items: readonly BlockItem[],
160
+ expandedKeys: ReadonlySet<string>,
161
+ width: number,
162
+ moreKey: string,
163
+ ): BlockRow[] =>
164
+ (expandedKeys.has(moreKey) ? items : items.slice(0, SHOWN_BLOCK_ROWS)).map((item) => rowOf(item, expandedKeys, width))
165
+
166
+ /** The rows the herd draws for what waits on the user. */
167
+ export const waitingRows = (
168
+ items: readonly WaitingItem[],
169
+ expandedKeys: ReadonlySet<string>,
170
+ width: number,
171
+ ): BlockRow[] => blockRows(items.map(itemOf), expandedKeys, width, WAITING_MORE_KEY)
172
+
173
+ /** The key of the `+<n>` line of a block with `count` points, null while the block has no more than it shows. */
174
+ export const moreKeyOf = (count: number, key: string): string | null => (count > SHOWN_BLOCK_ROWS ? key : null)
175
+
176
+ const rowAt = (
177
+ rows: readonly BlockRow[],
178
+ firstLine: number,
179
+ line: number,
180
+ ): { row: BlockRow; onFirstLine: boolean } | null => {
181
+ let first = firstLine
182
+ for (const row of rows) {
183
+ if (line < first + row.lines.length) return line >= first ? { row, onFirstLine: line === first } : null
184
+ first += row.lines.length
185
+ }
186
+ return null
187
+ }
188
+
189
+ const onAskButton = ({ askColumn }: BlockRow, column: number | undefined): boolean =>
190
+ askColumn !== null && column !== undefined && column >= askColumn && column < askColumn + textWidth(ASK_BUTTON)
191
+
192
+ const clickOn = (row: BlockRow, onFirstLine: boolean, column: number | undefined): BlockClick | null => {
193
+ const { key, cut, expanded, url, ask } = row
194
+ if (ask && onFirstLine && onAskButton(row, column)) return { ask }
195
+ if (url) return cut && !expanded ? { toggle: key } : { open: url }
196
+ return cut ? { toggle: key } : null
197
+ }
198
+
199
+ /** Whether a plain left click hit the head line of a block, which folds and opens it. `headLine` is 0-based. */
200
+ export const blockHeadClick = (
201
+ headLine: number,
202
+ { button, row, pressed }: Pick<MouseEvent, 'button' | 'row' | 'pressed'>,
203
+ ): boolean => pressed && button === MOUSE_BUTTON.left && row - 1 === headLine
204
+
205
+ /**
206
+ * What a plain left click on a block does. A click on the `[ask]` button of a question or a board row (the columns of
207
+ * its first line) asks the coordinator, whatever the fold. Any other click on a row that is cut off expands or folds it,
208
+ * except that a file or Artifact opens on the click after it expanded; a row that fits opens its file or does nothing.
209
+ * The `+<n>` line, the line right
210
+ * under the rows, toggles `moreKey` (all points or the first five); `moreKey` is null while the block has no such
211
+ * line. Other lines, a click with a modifier (ctrl is left to the terminal's own link opening), a release and the
212
+ * wheel do nothing. `firstLine` is the screen line (0-based) of the first row, as `renderScreen` returns it.
213
+ */
214
+ export const blockClick = (
215
+ rows: readonly BlockRow[],
216
+ firstLine: number,
217
+ {
218
+ button,
219
+ row,
220
+ pressed,
221
+ column,
222
+ }: Pick<MouseEvent, 'button' | 'row' | 'pressed'> & Partial<Pick<MouseEvent, 'column'>>,
223
+ moreKey: string | null = null,
224
+ ): BlockClick | null => {
225
+ if (!pressed || button !== MOUSE_BUTTON.left) return null
226
+ const line = row - 1
227
+ const moreLine = firstLine + rows.reduce((sum, { lines }) => sum + lines.length, 0)
228
+ if (moreKey !== null && line === moreLine) return { toggle: moreKey }
229
+ const target = rowAt(rows, firstLine, line)
230
+ return target ? clickOn(target.row, target.onFirstLine, column) : null
231
+ }
@@ -0,0 +1,39 @@
1
+ import { openWaitingItems, type WaitingItem } from '../plan-format/waiting'
2
+ import type { PlanLanguage } from '../vocabulary'
3
+ import { type QuestionRecord, questionWaitingItems } from './question-records'
4
+ import { readQuestionRecords } from './questions-read'
5
+ import { type SentFileRecord, sentWaitingItems } from './sent-files'
6
+ import { readSentRecords } from './sent-files-read'
7
+
8
+ /** The two logs that wait on the user, as read from the main checkout. */
9
+ export type WaitingRecords = { sent: SentFileRecord[]; questions: QuestionRecord[] }
10
+
11
+ /** The files sent to the user and the questions left unanswered that no one closed, files first. */
12
+ export const waitingItemsOfRecords = (
13
+ sent: readonly SentFileRecord[],
14
+ questions: readonly QuestionRecord[],
15
+ now: number,
16
+ ): WaitingItem[] => [...sentWaitingItems(sent, now), ...questionWaitingItems(questions, now)]
17
+
18
+ /** What waits on the user: the board's own rows (none when `board` is null), then the sends and questions. */
19
+ export const waitingItemsOf = (
20
+ board: string | null,
21
+ language: PlanLanguage,
22
+ { sent, questions }: WaitingRecords,
23
+ now: number,
24
+ ): WaitingItem[] => [
25
+ ...(board === null ? [] : openWaitingItems(board, language)),
26
+ ...waitingItemsOfRecords(sent, questions, now),
27
+ ]
28
+
29
+ /** Both logs read from `root`; each file is read once, whoever uses it. */
30
+ export const readWaitingRecords = (root: string): WaitingRecords => ({
31
+ sent: readSentRecords(root),
32
+ questions: readQuestionRecords(root),
33
+ })
34
+
35
+ /** The files sent to the user and the questions left unanswered that no one closed, files first, read from `root`. */
36
+ export const recordWaitingItems = (root: string, now: number): WaitingItem[] => {
37
+ const { sent, questions } = readWaitingRecords(root)
38
+ return waitingItemsOfRecords(sent, questions, now)
39
+ }
@@ -0,0 +1,8 @@
1
+ import { DAY_MS } from '../plan-format/clock'
2
+
3
+ /** A question or a sent file waits on the user this long at most; a later answer or `jh waiting close` ends it earlier. */
4
+ const WAITING_DAYS = 3
5
+
6
+ /** Whether a record that was closed at `closedAt` (null: never) and began at `at` still waits on the user at `now`. */
7
+ export const isStillWaiting = (closedAt: number | null, at: number, now: number): boolean =>
8
+ closedAt === null && now - at <= WAITING_DAYS * DAY_MS
@@ -0,0 +1,105 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
2
+ import { basename, dirname, join, resolve } from 'node:path'
3
+ import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
4
+ import { pageDirectoryOf, systemLocation } from '../shared/private-location'
5
+ import { openInBrowser } from '../terminal/open'
6
+ import { runGh } from '../watch/gh'
7
+ import { embedExternalImages, fetchImage } from './embed'
8
+ import { rewriteImages } from './images'
9
+ import { mediaTypeOf } from './open'
10
+ import { asksForHelp, PREVIEW_USAGE, parsePreviewOptions } from './options'
11
+ import { previewPage } from './page'
12
+ import { renderMarkdown, repositorySlugOf } from './render'
13
+
14
+ type PackageFacts = { root: string; name: string }
15
+
16
+ const packageOf = (file: string): PackageFacts | null => {
17
+ for (let directory = dirname(file); ; directory = dirname(directory)) {
18
+ const manifest = join(directory, 'package.json')
19
+ if (existsSync(manifest)) {
20
+ const name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name
21
+ if (typeof name === 'string') return { root: directory, name }
22
+ }
23
+ if (dirname(directory) === directory) return null
24
+ }
25
+ }
26
+
27
+ const remoteSlugOf = (directory: string): string | null => {
28
+ const result = Bun.spawnSync(['git', '-C', directory, 'remote', 'get-url', 'origin'], {
29
+ stdout: 'pipe',
30
+ stderr: 'pipe',
31
+ })
32
+ return result.exitCode === 0 ? repositorySlugOf(result.stdout.toString()) : null
33
+ }
34
+
35
+ /** Whether `file` is a regular file whose real path, links followed, lies under the real path of `root`. */
36
+ const isRegularFileUnder = (root: string, file: string): boolean => {
37
+ if (!existsSync(file)) return false
38
+ return realpathSync(file).startsWith(`${realpathSync(root)}/`) && statSync(file).isFile()
39
+ }
40
+
41
+ /**
42
+ * The image at `path` under `root` as a data address; null when it is missing, no image, no regular file, or outside
43
+ * `root` once links are followed, so a link in the working tree cannot put a file from elsewhere into the page.
44
+ */
45
+ export const inlineImage = (root: string, path: string): string | null => {
46
+ const file = resolve(root, path)
47
+ const type = mediaTypeOf(file)
48
+ if (type === null || !file.startsWith(`${root}/`) || !isRegularFileUnder(root, file)) return null
49
+ return `data:${type};base64,${readFileSync(file).toString('base64')}`
50
+ }
51
+
52
+ /**
53
+ * Writes the page to `out` when the user named one, else to `preview-<name>.html` in the user's private page directory
54
+ * (verified by `ensurePrivateDirectory`, written through `writePrivateFile`), never to a fixed name in the shared
55
+ * temporary directory; returns the path written.
56
+ */
57
+ export const writePreviewPage = (
58
+ file: string,
59
+ page: string,
60
+ out: string | null,
61
+ pageDirectory: string = pageDirectoryOf(systemLocation()),
62
+ ): string => {
63
+ if (out !== null) {
64
+ const chosen = resolve(out)
65
+ writeFileSync(chosen, page)
66
+ return chosen
67
+ }
68
+ ensurePrivateDirectory(pageDirectory)
69
+ const path = join(pageDirectory, `preview-${basename(file, '.md')}.html`)
70
+ writePrivateFile(path, page)
71
+ return path
72
+ }
73
+
74
+ /**
75
+ * `jh preview <markdown-file> [--out <path>] [--open]`: renders the file through GitHub's markdown API and writes a
76
+ * page that shows it in GitHub and in npm style, light and dark, and at phone width. The package's own images come
77
+ * from the working tree, so the preview shows what is not published yet. External images (badges) are embedded as data addresses, so the page is complete where it may load nothing from outside; one that cannot be fetched stays linked, with one warning. Prints the path of the page; `--open`
78
+ * opens it. `--help` prints the usage. Exit 0 written, 1 failed (`gh` missing or not signed in, file unreadable).
79
+ */
80
+ export const run = async (args: string[]): Promise<number> => {
81
+ if (asksForHelp(args)) {
82
+ console.log(PREVIEW_USAGE)
83
+ return 0
84
+ }
85
+ try {
86
+ const options = parsePreviewOptions(args)
87
+ const file = resolve(options.file)
88
+ if (!existsSync(file)) throw new Error(`${options.file} does not exist`)
89
+ const facts = packageOf(file)
90
+ const html = await renderMarkdown(readFileSync(file, 'utf8'), remoteSlugOf(dirname(file)), dirname(file), runGh)
91
+ const own = facts ?? { root: dirname(file), name: '' }
92
+ const local = rewriteImages(html, own.name, (path) => inlineImage(own.root, path))
93
+ const { html: rewritten, failed } = await embedExternalImages(local, fetchImage)
94
+ if (failed > 0)
95
+ console.error(`${failed} external ${failed === 1 ? 'image' : 'images'} could not be embedded and stay linked`)
96
+ const out = writePreviewPage(file, previewPage({ title: basename(file), html: rewritten }), options.out)
97
+ console.log(out)
98
+ const reason = options.open ? openInBrowser(out) : null
99
+ if (reason) console.error(`could not open the page: ${reason}`)
100
+ return 0
101
+ } catch (error) {
102
+ console.error(error instanceof Error ? error.message : String(error))
103
+ return 1
104
+ }
105
+ }