jackherd 0.0.1 → 1.0.1

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 +65 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1122 -3
  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,301 @@
1
+ import { contextLabel } from '../context'
2
+ import { CLI_NAME } from '../version/release'
3
+ import type { LinkChip } from './link-chips'
4
+ import { renderPlanMarkdown, scrollable } from './markdown-html'
5
+ import { phaseList, plannedPhaseCount } from './planned'
6
+ import type {
7
+ PageLane,
8
+ PagePhase,
9
+ PagePlanDocument,
10
+ PagePlanned,
11
+ PagePlanRow,
12
+ PagePlanTable,
13
+ PageStep,
14
+ PlanRef,
15
+ SprintPage,
16
+ } from './sprint-page'
17
+ import { phaseCountOf, phaseStateOf, statusSymbol, statusWord } from './symbols'
18
+ import { laneMarkText, TEXTS } from './texts'
19
+
20
+ const BAR_PERCENT = 100
21
+
22
+ const STYLE = `
23
+ :root { color-scheme: light dark; --bg: #fafaf9; --panel: #ffffff; --fg: #1c1917; --muted: #78716c; --line: #e7e5e4;
24
+ --accent: #2563eb; --ok: #15803d; --warn: #b45309; --bad: #b91c1c; --bar: #e7e5e4; }
25
+ @media (prefers-color-scheme: dark) { :root { --bg: #131315; --panel: #1c1c1f; --fg: #e7e5e4; --muted: #a8a29e;
26
+ --line: #2e2e33; --accent: #60a5fa; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171; --bar: #2e2e33; } }
27
+ * { box-sizing: border-box; }
28
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
29
+ main { max-width: 1040px; margin: 0 auto; padding: 24px 16px 64px; }
30
+ a { color: var(--accent); }
31
+ code { font: 0.9em ui-monospace, monospace; background: var(--line); padding: 1px 4px; border-radius: 4px; }
32
+ h1 { margin: 0 0 4px; font-size: 1.6rem; }
33
+ h2 { margin: 32px 0 12px; font-size: 1.15rem; }
34
+ h3 { margin: 16px 0 4px; font-size: 1rem; }
35
+ .meta, .muted { color: var(--muted); font-size: 0.9rem; }
36
+ .goal p { margin: 8px 0; white-space: pre-line; }
37
+ .lane { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; margin: 12px 0; }
38
+ .lane > summary { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: center; padding: 10px 14px; cursor: pointer; }
39
+ .lane.closed { opacity: 0.6; }
40
+ .name { font-size: 1.02rem; }
41
+ .word { color: var(--muted); }
42
+ .context { color: var(--muted); font-size: 0.9rem; font-variant-numeric: tabular-nums; }
43
+ .context.warn { color: var(--warn); } .context.alert { color: var(--bad); }
44
+ .bar { display: inline-block; width: 120px; height: 8px; background: var(--bar); border-radius: 4px; overflow: hidden; }
45
+ .bar i { display: block; height: 100%; background: var(--ok); }
46
+ .count { font-variant-numeric: tabular-nums; }
47
+ .grow { flex: 1; }
48
+ .lane-body { padding: 0 14px 14px; border-top: 1px solid var(--line); }
49
+ .step { margin-top: 12px; }
50
+ .step-meta { margin: 0; color: var(--muted); font-size: 0.88rem; }
51
+ .note { margin: 4px 0 0; }
52
+ ul.phases { list-style: none; margin: 6px 0 0; padding: 0; }
53
+ .phase { padding: 4px 0; border-top: 1px dashed var(--line); }
54
+ .phase:first-child { border-top: 0; }
55
+ .phase .count { display: inline-block; min-width: 4.5em; margin-right: .4em; font-variant-numeric: tabular-nums; }
56
+ .state-all .count { color: var(--ok); }
57
+ .state-missing, .state-empty { color: var(--muted); }
58
+ .deps { color: var(--muted); font-size: 0.85rem; margin-left: 1.4em; }
59
+ details.items { margin-left: 1.4em; }
60
+ details.items summary { cursor: pointer; color: var(--muted); font-size: 0.88rem; }
61
+ ul.items { margin: 4px 0 0; padding-left: 1.2em; }
62
+ .pr { margin: 2px 0; }
63
+ .checks-passed { color: var(--ok); } .checks-failed { color: var(--bad); } .checks-pending { color: var(--warn); }
64
+ /* tables sit in .table-scroll so a wide one scrolls sideways */
65
+ .planned { opacity: 0.7; margin: 10px 0; }
66
+ .planned > summary { cursor: pointer; }
67
+ .table-scroll { overflow-x: auto; margin: 8px 0; }
68
+ .chips { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
69
+ .chip { display: inline-flex; gap: 4px; align-items: baseline; border: 1px solid var(--line); border-radius: 999px; padding: 1px 10px; font-size: 0.85rem; }
70
+ .chip .icon { margin-right: 4px; }
71
+ table { border-collapse: collapse; width: 100%; background: var(--panel); }
72
+ table.plans { table-layout: fixed; }
73
+ table.plans .bar { display: block; width: 100%; margin-bottom: 2px; }
74
+ .table-caption { margin: 16px 0 4px; }
75
+ .gh { font-size: 0.8rem; color: var(--muted); }
76
+ .plan { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; margin: 10px 0; }
77
+ .plan > summary { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: center; padding: 10px 14px; cursor: pointer; }
78
+ .plan-body { padding: 0 14px 14px; border-top: 1px solid var(--line); overflow-x: auto; }
79
+ .plan-body h3, .plan-body h4, .plan-body h5 { margin: 18px 0 6px; }
80
+ th, td { border: 1px solid var(--line); padding: 6px 10px; text-align: left; vertical-align: top; }
81
+ th { color: var(--muted); font-weight: 600; font-size: 0.85rem; }
82
+ footer { margin-top: 40px; color: var(--muted); font-size: 0.85rem; }
83
+ `
84
+
85
+ export const escapeHtml = (text: string): string =>
86
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
87
+
88
+ /** A private-use character that cannot occur in escaped text; it keeps a finished link out of the bold and code passes. */
89
+ const PLACEHOLDER = '\uE000'
90
+ const SAFE_LINK = /^https?:\/\//
91
+ const SAFE_TARGET = /^(?:https?|file):\/\/|^#/
92
+
93
+ /** A link target the page may use: http(s) for the web, `file:` for the local checkout, `#` for a place on the page; never a script. */
94
+ export const isSafeTarget = (target: string): boolean => SAFE_TARGET.test(target)
95
+
96
+ const linked = (target: string | null, label: string): string =>
97
+ target !== null && isSafeTarget(target) ? `<a href="${escapeHtml(target)}">${label}</a>` : label
98
+
99
+ /** A plan's title linked to its section on the page, with a small link to its file beside it. */
100
+ const planLink = (plan: PlanRef | null, label: string): string => {
101
+ if (plan === null) return label
102
+ const file = linked(plan.file, 'on GitHub')
103
+ return plan.anchor === null
104
+ ? linked(plan.file, label)
105
+ : `${linked(`#${plan.anchor}`, label)} <span class="gh">(${file})</span>`
106
+ }
107
+
108
+ /**
109
+ * Escaped text with the markdown a sprint document uses inline: `**bold**`, `` `code` `` and
110
+ * `[text](https://…)`; a link that is not http(s) is shown as its text, never as a link.
111
+ */
112
+ export const inlineMarkdown = (text: string): string => {
113
+ const anchors: string[] = []
114
+ const withoutLinks = escapeHtml(text).replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label: string, href: string) => {
115
+ anchors.push(SAFE_LINK.test(href) ? `<a href="${href}">${label}</a>` : label)
116
+ return `${PLACEHOLDER}${anchors.length - 1}${PLACEHOLDER}`
117
+ })
118
+ return withoutLinks
119
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
120
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
121
+ .replace(new RegExp(`${PLACEHOLDER}(\\d+)${PLACEHOLDER}`, 'g'), (_, index: string) => anchors[Number(index)])
122
+ }
123
+
124
+ const plainMarkdown = (text: string): string => text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/\*\*|`/g, '')
125
+
126
+ const timestamp = (date: Date): string => `${date.toISOString().slice(0, 16).replace('T', ' ')} UTC`
127
+
128
+ const baseRefLine = ({ baseRef }: SprintPage): string => {
129
+ const commit = baseRef.commit ? ` @ <code>${escapeHtml(baseRef.commit)}</code>` : ' (not found)'
130
+ const committed = baseRef.committedAt ? `, committed ${escapeHtml(baseRef.committedAt)}` : ''
131
+ const fetched = baseRef.fetchedAt ? `last fetch ${timestamp(baseRef.fetchedAt)}` : 'last fetch unknown'
132
+ return `${escapeHtml(baseRef.name)}${commit}${committed} · ${fetched}`
133
+ }
134
+
135
+ const percent = (done: number, total: number): number => (total === 0 ? 0 : Math.floor((done / total) * BAR_PERCENT))
136
+
137
+ const bar = (done: number, total: number): string =>
138
+ total === 0
139
+ ? ''
140
+ : `<span class="bar" role="img" aria-label="${done} of ${total}"><i style="width:${percent(done, total)}%"></i></span> <span class="count">${done}/${total}</span>`
141
+
142
+ const branchLine = ({ branch, ahead, dirty }: PageLane): string => {
143
+ if (!branch) return `<span class="muted">${escapeHtml(TEXTS.noWorktree)}</span>`
144
+ const marks = [ahead ? `↑${ahead}` : '', dirty ? `±${dirty}` : ''].filter(Boolean).join(' ')
145
+ return `⎇ <code>${escapeHtml(branch)}</code>${marks ? ` ${marks}` : ''}`
146
+ }
147
+
148
+ const pullRequestLine = (lane: PageLane): string =>
149
+ lane.pullRequests
150
+ .map(
151
+ (pull) =>
152
+ `<div class="pr">↗ ${linked(pull.url, `#${pull.number}`)} <span class="checks-${pull.checks}">${escapeHtml(TEXTS.checks[pull.checks])}</span> ${escapeHtml(pull.draft ? TEXTS.draft : '')}${escapeHtml(pull.title)}</div>`,
153
+ )
154
+ .join('')
155
+
156
+ const phaseTitle = (phase: PagePhase): string =>
157
+ phase.title === null ? `<span class="muted">${escapeHtml(TEXTS.notInPlan)}</span>` : inlineMarkdown(phase.title)
158
+
159
+ const openItems = (phase: PagePhase): string => {
160
+ const open = phase.items.filter((item) => !item.done)
161
+ if (open.length === 0) return ''
162
+ const rows = open.map((item) => `<li>${inlineMarkdown(item.text)}</li>`).join('')
163
+ return `<details class="items"><summary>${open.length} open</summary><ul class="items">${rows}</ul></details>`
164
+ }
165
+
166
+ const phaseRow = (phase: PagePhase): string => {
167
+ const state = phaseStateOf(phase)
168
+ const deps = phase.dependsOn
169
+ ? `<div class="deps">depends on ${inlineMarkdown(plainMarkdown(phase.dependsOn))}</div>`
170
+ : ''
171
+ return `<li class="phase state-${state}"><span class="count">${phaseCountOf(phase)}</span><strong>P${escapeHtml(phase.id)}</strong> ${phaseTitle(phase)}${deps}${openItems(phase)}</li>`
172
+ }
173
+
174
+ const stepBlock = (step: PageStep): string => {
175
+ const title = planLink(step.plan, escapeHtml(step.planTitle))
176
+ const facts = [
177
+ step.model ? `Model: ${escapeHtml(step.model)}` : '',
178
+ step.startsAfter ? `Starts after: ${inlineMarkdown(plainMarkdown(step.startsAfter))}` : '',
179
+ ]
180
+ .filter(Boolean)
181
+ .join(' · ')
182
+ const phases = step.phases.length > 0 ? `<ul class="phases">${step.phases.map(phaseRow).join('')}</ul>` : ''
183
+ const note = step.note ? `<p class="note">${inlineMarkdown(step.note)}</p>` : ''
184
+ return `<section class="step"><h3>${title}</h3>${facts ? `<p class="step-meta">${facts}</p>` : ''}${note}${phases}</section>`
185
+ }
186
+
187
+ const laneBlock = (lane: PageLane): string => {
188
+ const port = lane.port === null ? '' : `<span class="meta">port ${lane.port}</span>`
189
+ const closed = lane.closed ? '<span class="meta">closed</span>' : ''
190
+ const markText = laneMarkText(lane)
191
+ const mark = markText ? `<span class="meta">${escapeHtml(markText)}</span> ` : ''
192
+ const context = lane.context
193
+ ? `<span class="context ${lane.context.level}" title="context window">${contextLabel(lane.context.share)}</span> `
194
+ : ''
195
+ const summary = `<summary><span title="${escapeHtml(statusWord(lane.agent))}">${statusSymbol(lane.agent)}</span> <strong class="name">${escapeHtml(lane.name)}</strong> <span class="word">${escapeHtml(statusWord(lane.agent))}</span> ${port} ${closed}${mark}<span class="grow"></span>${context}${bar(lane.done, lane.total)}</summary>`
196
+ const facts = `<p class="meta">${branchLine(lane)}</p>${pullRequestLine(lane)}`
197
+ return `<details class="lane${lane.closed ? ' closed' : ''}"${lane.closed ? '' : ' open'}>${summary}<div class="lane-body">${facts}${lane.steps.map(stepBlock).join('')}</div></details>`
198
+ }
199
+
200
+ const PLAN_LINK_IN_CELL = /\[[^\]]+\]\([^)\s]+\)/
201
+
202
+ const planCell = (cell: string, row: PagePlanRow): string =>
203
+ row.title !== null && PLAN_LINK_IN_CELL.test(cell)
204
+ ? cell.replace(PLAN_LINK_IN_CELL, () => planLink(row.plan, escapeHtml(row.title ?? '')))
205
+ : inlineMarkdown(cell)
206
+
207
+ const planProgress = ({ progress }: PagePlanRow): string =>
208
+ progress === null || progress.total === 0
209
+ ? '<td class="muted">–</td>'
210
+ : `<td>${bar(progress.done, progress.total)}</td>`
211
+
212
+ /** Column widths shared by every plans table, so tables that belong together line up. */
213
+ const PLAN_COLUMNS = ['4%', '24%', '34%', '30%', '8%']
214
+
215
+ const planColumns = (count: number): string =>
216
+ `<colgroup>${PLAN_COLUMNS.slice(0, count)
217
+ .map((width) => `<col style="width:${width}">`)
218
+ .join('')}</colgroup>`
219
+
220
+ const planTable = ({ caption, header, rows }: PagePlanTable): string => {
221
+ const head = `${header.map((cell) => `<th>${escapeHtml(cell)}</th>`).join('')}<th>Checkboxes</th>`
222
+ const body = rows
223
+ .map((row) => `<tr>${row.cells.map((cell) => `<td>${planCell(cell, row)}</td>`).join('')}${planProgress(row)}</tr>`)
224
+ .join('')
225
+ const title = caption ? `<p class="table-caption">${inlineMarkdown(caption)}</p>` : ''
226
+ return `${title}${scrollable(`<table class="plans">${planColumns(header.length + 1)}<thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`)}`
227
+ }
228
+
229
+ const plansSection = ({ plans }: SprintPage): string =>
230
+ plans.length === 0 ? '' : `<h2>Plans</h2>${plans.map(planTable).join('')}`
231
+
232
+ const plannedRow = ({ title, plan, phaseIds, done, total }: PagePlanned): string =>
233
+ `<li>${planLink(plan, escapeHtml(title))} <span class="muted">${escapeHtml(phaseList({ phaseIds }))}</span> ${bar(done, total)}</li>`
234
+
235
+ /** The open phases no lane works on, dimmed and closed, so the page keeps its focus on the active lanes. */
236
+ const plannedSection = ({ planned }: SprintPage): string => {
237
+ if (planned.length === 0) return ''
238
+ const phases = plannedPhaseCount(planned)
239
+ const summary = `<summary>○ ${escapeHtml(TEXTS.planned(phases, planned.length))}</summary>`
240
+ return `<details class="planned">${summary}<ul class="phases">${planned.map(plannedRow).join('')}</ul></details>`
241
+ }
242
+
243
+ const chip = (link: LinkChip): string => {
244
+ const hint = link.private ? ' <span class="muted">privat</span>' : ''
245
+ return `<span class="chip">${linked(link.url, `<span class="icon">${escapeHtml(link.icon)}</span>${escapeHtml(link.label)}`)}${hint}</span>`
246
+ }
247
+
248
+ /** The markdown of one plan section as HTML, kept by `renderPlanMarkdown`; calling it ahead of `renderSprintHtml` moves the work out of the render. */
249
+ export const renderPlanBody = (doc: PagePlanDocument, page: SprintPage): string =>
250
+ renderPlanMarkdown(doc.markdown, { fromPath: doc.repoPath, ...page.links }, doc.anchor, doc.title)
251
+
252
+ const planDocument = (doc: PagePlanDocument, page: SprintPage): string => {
253
+ const status = doc.status ? `<span class="muted">${inlineMarkdown(doc.status)}</span>` : ''
254
+ const progress = doc.progress.total > 0 ? bar(doc.progress.done, doc.progress.total) : ''
255
+ const chips = doc.chips.length > 0 ? `<span class="chips">${doc.chips.map(chip).join('')}</span>` : ''
256
+ const summary = `<summary><strong>${escapeHtml(doc.title)}</strong> ${status}<span class="grow"></span>${progress}${chips}</summary>`
257
+ const body = renderPlanBody(doc, page)
258
+ return `<details class="plan">${summary}<div class="plan-body"><a id="${escapeHtml(doc.anchor)}"></a><p class="meta">${linked(doc.file, 'on GitHub')}</p>${body}</div></details>`
259
+ }
260
+
261
+ const planDocuments = (page: SprintPage): string =>
262
+ page.planDocuments.length === 0
263
+ ? ''
264
+ : `<h2>Plan documents</h2>${page.planDocuments.map((doc) => planDocument(doc, page)).join('\n')}`
265
+
266
+ const historyTable = ({ document }: SprintPage): string => {
267
+ const { header, rows } = document.history
268
+ if (rows.length === 0) return ''
269
+ const head = header.map((cell) => `<th>${escapeHtml(cell)}</th>`).join('')
270
+ const body = rows.map((row) => `<tr>${row.map((cell) => `<td>${inlineMarkdown(cell)}</td>`).join('')}</tr>`).join('')
271
+ return `<h2>History</h2>${scrollable(`<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`)}`
272
+ }
273
+
274
+ /** One page for the whole board: one file, inline CSS, light and dark by the reader's setting, no script. */
275
+ export const renderSprintHtml = (page: SprintPage): string => {
276
+ const goal = page.document.goal.map((paragraph) => `<p>${inlineMarkdown(paragraph)}</p>`).join('')
277
+ return `<!doctype html>
278
+ <html lang="en">
279
+ <head>
280
+ <meta charset="utf-8">
281
+ <meta name="viewport" content="width=device-width, initial-scale=1">
282
+ <title>${escapeHtml(page.title)}</title>
283
+ <style>${STYLE}</style>
284
+ </head>
285
+ <body>
286
+ <main>
287
+ <h1>${escapeHtml(page.title)}</h1>
288
+ <p class="meta">Snapshot of ${timestamp(page.generatedAt)} · ${baseRefLine(page)}</p>
289
+ ${goal ? `<section class="goal">${goal}</section>` : ''}
290
+ ${plansSection(page)}
291
+ <h2>Lanes</h2>
292
+ ${page.lanes.map(laneBlock).join('\n')}
293
+ ${plannedSection(page)}
294
+ ${historyTable(page)}
295
+ ${planDocuments(page)}
296
+ <footer>Generated by <code>${CLI_NAME} plans</code>. A snapshot, not a source: the markdown files stay the truth.</footer>
297
+ </main>
298
+ </body>
299
+ </html>
300
+ `
301
+ }
@@ -0,0 +1,287 @@
1
+ import { join, posix } from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import type { LinkKind } from '../config/link-kinds'
4
+ import type { ContextReading } from '../context'
5
+ import {
6
+ type ChecklistItem,
7
+ type PhaseProgress,
8
+ parsePlanPhases,
9
+ readPhaseDependencies,
10
+ readPlanHeader,
11
+ readSprintDocument,
12
+ resolvePlanPath,
13
+ type SprintDocument,
14
+ } from '../plan-format'
15
+ import type { PlanLanguage } from '../vocabulary'
16
+ import type { LaneView } from './layout'
17
+ import { extractLinkChips, type LinkChip } from './link-chips'
18
+ import { planAnchorOf } from './markdown-html'
19
+ import type { PullRequest } from './pull-requests'
20
+ import type { Snapshot } from './snapshot'
21
+
22
+ /** The base ref the page was read from: its name, its commit and when this clone last fetched it. */
23
+ export type BaseRefInfo = { name: string; commit: string | null; committedAt: string | null; fetchedAt: Date | null }
24
+
25
+ /** What the page needs besides the snapshot; read by the caller, so the page itself is a pure function. */
26
+ export type PageContext = {
27
+ /** The main checkout, so a plan link can be a `file:` URL when the remote is unknown. */
28
+ root: string
29
+ generatedAt: Date
30
+ baseRef: BaseRefInfo
31
+ /** `https://github.com/<owner>/<repo>/blob/<branch>/`, null when the remote is unknown. */
32
+ repoBlobBase: string | null
33
+ /** The directory of the sprint document, relative to the repository. */
34
+ sprintDirectory: string
35
+ language: PlanLanguage
36
+ /** The kinds of link a plan's text is searched for, shown as chips on the plan's section. */
37
+ linkKinds: readonly LinkKind[]
38
+ }
39
+
40
+ export type PagePhase = {
41
+ id: string
42
+ /** Null when the plan does not have the phase. */
43
+ title: string | null
44
+ done: number
45
+ total: number
46
+ items: ChecklistItem[]
47
+ dependsOn: string | null
48
+ }
49
+
50
+ /**
51
+ * How a plan is reached from the page: the id of its section when the page carries the plan, and its file on
52
+ * GitHub (a `file:` URL of the checkout when the remote is unknown).
53
+ */
54
+ export type PlanRef = { anchor: string | null; file: string }
55
+
56
+ export type PageStep = {
57
+ planTitle: string
58
+ plan: PlanRef | null
59
+ model: string
60
+ startsAfter: string
61
+ note: string
62
+ phases: PagePhase[]
63
+ }
64
+
65
+ export type PageLane = {
66
+ name: string
67
+ port: number | null
68
+ closed: boolean
69
+ agent: string | null
70
+ /** How full the agent's context is; absent when the lane has no reading. */
71
+ context?: ContextReading | null
72
+ branch: string | null
73
+ ahead: number | null
74
+ dirty: number
75
+ pullRequests: PullRequest[]
76
+ steps: PageStep[]
77
+ done: number
78
+ total: number
79
+ /** A running lane the sprint table has no row for; absent for a lane the table lists. */
80
+ unlisted?: boolean
81
+ }
82
+
83
+ export type PagePlanRow = {
84
+ cells: string[]
85
+ title: string | null
86
+ plan: PlanRef | null
87
+ /** Ticked and all checkboxes over the plan's phases; null when the plan could not be read. */
88
+ progress: { done: number; total: number } | null
89
+ }
90
+
91
+ export type PagePlanTable = { caption: string | null; header: string[]; rows: PagePlanRow[] }
92
+
93
+ /** A plan the page carries in full: its section, what its header says, and the markdown to render. */
94
+ export type PagePlanDocument = {
95
+ anchor: string
96
+ /** Repository path of the plan file. */
97
+ repoPath: string
98
+ title: string
99
+ status: string
100
+ progress: { done: number; total: number }
101
+ file: string
102
+ markdown: string
103
+ chips: LinkChip[]
104
+ }
105
+
106
+ /** A plan with open phases no lane with an agent leads, as the page lists it; `plan` links its section or its file. */
107
+ export type PagePlanned = {
108
+ title: string
109
+ plan: PlanRef
110
+ phaseIds: string[]
111
+ done: number
112
+ total: number
113
+ }
114
+
115
+ export type SprintPage = {
116
+ title: string
117
+ generatedAt: Date
118
+ baseRef: BaseRefInfo
119
+ document: SprintDocument
120
+ plans: PagePlanTable[]
121
+ lanes: PageLane[]
122
+ planDocuments: PagePlanDocument[]
123
+ planned: PagePlanned[]
124
+ /** What the renderer needs to point a link of a plan at the right place. */
125
+ links: { anchors: ReadonlyMap<string, string>; blobBase: string | null; root: string }
126
+ }
127
+
128
+ const fileLink = (path: string, context: PageContext): string =>
129
+ context.repoBlobBase ? `${context.repoBlobBase}${path}` : pathToFileURL(join(context.root, path)).href
130
+
131
+ /** The plans of the page by link as the sprint writes it: those whose text was read have a section. */
132
+ const planRefs = (snapshot: Snapshot, hrefs: readonly string[], context: PageContext): Map<string, PlanRef> => {
133
+ const taken = new Set<string>()
134
+ const refs = new Map<string, PlanRef>()
135
+ for (const href of hrefs) {
136
+ if (refs.has(href)) continue
137
+ const path = resolvePlanPath(context.sprintDirectory, href)
138
+ const base = planAnchorOf(path)
139
+ const anchor = href in snapshot.documents.plans ? uniqueId(base, taken) : null
140
+ refs.set(href, { anchor, file: fileLink(path, context) })
141
+ }
142
+ return refs
143
+ }
144
+
145
+ const uniqueId = (base: string, taken: Set<string>): string => {
146
+ let id = base
147
+ for (let count = 2; taken.has(id); count += 1) id = `${base}-${count}`
148
+ taken.add(id)
149
+ return id
150
+ }
151
+
152
+ const phaseOf = (phase: PhaseProgress, dependencies: Record<string, string>): PagePhase => {
153
+ const numbered = phase.id.replace(/[a-z]$/, '')
154
+ return {
155
+ id: phase.id,
156
+ title: phase.title,
157
+ done: phase.done,
158
+ total: phase.total,
159
+ items: phase.items,
160
+ dependsOn: dependencies[phase.id] ?? dependencies[numbered] ?? null,
161
+ }
162
+ }
163
+
164
+ type DependenciesByPlan = ReadonlyMap<string, Record<string, string>>
165
+
166
+ const laneOf = (view: LaneView, dependencies: DependenciesByPlan, refs: ReadonlyMap<string, PlanRef>): PageLane => {
167
+ const { progress, status, pullRequests } = view
168
+ return {
169
+ name: progress.lane.name,
170
+ port: progress.lane.port,
171
+ closed: progress.lane.closed,
172
+ agent: status.agent,
173
+ context: view.context ?? null,
174
+ branch: status.branch,
175
+ ahead: status.ahead,
176
+ dirty: status.dirty,
177
+ pullRequests,
178
+ done: progress.done,
179
+ total: progress.total,
180
+ ...(view.unlisted ? { unlisted: true } : {}),
181
+ steps: progress.steps.map(({ step, phases }) => ({
182
+ planTitle: step.planTitle,
183
+ plan: step.planHref ? (refs.get(step.planHref) ?? null) : null,
184
+ model: step.model,
185
+ startsAfter: step.startsAfter,
186
+ note: step.label,
187
+ phases: phases.map((phase) => phaseOf(phase, (step.planHref && dependencies.get(step.planHref)) || {})),
188
+ })),
189
+ }
190
+ }
191
+
192
+ const progressOf = (text: string | undefined, language: PlanLanguage): PagePlanRow['progress'] => {
193
+ if (text === undefined) return null
194
+ const items = parsePlanPhases(text, language).flatMap((phase) => phase.items)
195
+ return { done: items.filter((item) => item.done).length, total: items.length }
196
+ }
197
+
198
+ /** The plans the sprint lists, as the sprint lists them, with how far each plan's checkboxes are ticked. */
199
+ const planTablesOf = (
200
+ snapshot: Snapshot,
201
+ document: SprintDocument,
202
+ refs: ReadonlyMap<string, PlanRef>,
203
+ context: PageContext,
204
+ ): PagePlanTable[] =>
205
+ document.planTables.map(({ caption, header, rows }) => ({
206
+ caption,
207
+ header,
208
+ rows: rows.map((row) => ({
209
+ cells: row.cells,
210
+ title: row.title,
211
+ plan: row.href ? (refs.get(row.href) ?? null) : null,
212
+ progress: progressOf(row.href ? snapshot.documents.plans[row.href] : undefined, context.language),
213
+ })),
214
+ }))
215
+
216
+ const documentsOf = (
217
+ snapshot: Snapshot,
218
+ refs: ReadonlyMap<string, PlanRef>,
219
+ context: PageContext,
220
+ ): PagePlanDocument[] =>
221
+ [...refs.entries()].flatMap(([href, ref]) => {
222
+ const markdown = snapshot.documents.plans[href]
223
+ if (markdown === undefined || ref.anchor === null) return []
224
+ const repoPath = resolvePlanPath(context.sprintDirectory, href)
225
+ const header = readPlanHeader(markdown, context.language, posix.basename(repoPath, '.md'))
226
+ return [
227
+ {
228
+ anchor: ref.anchor,
229
+ repoPath,
230
+ title: header.title,
231
+ status: header.status,
232
+ progress: progressOf(markdown, context.language) ?? { done: 0, total: 0 },
233
+ file: ref.file,
234
+ markdown,
235
+ chips: extractLinkChips(markdown, context.linkKinds),
236
+ },
237
+ ]
238
+ })
239
+
240
+ /** The links the page names plans by, in the order the sprint names them: its plans tables first, then the lanes. */
241
+ const hrefsOf = (document: SprintDocument, views: readonly LaneView[]): string[] => [
242
+ ...document.planTables.flatMap(({ rows }) => rows.flatMap((row) => (row.href ? [row.href] : []))),
243
+ ...views.flatMap(({ progress }) => progress.steps.flatMap(({ step }) => (step.planHref ? [step.planHref] : []))),
244
+ ]
245
+
246
+ /** The whole sprint as a page: every lane of the table in table order, closed and waiting ones too. */
247
+ export const buildSprintPage = (snapshot: Snapshot, context: PageContext): SprintPage => {
248
+ const order = snapshot.documents.laneOrder
249
+ const views = [...snapshot.views].sort(
250
+ (left, right) => order.indexOf(left.progress.lane.name) - order.indexOf(right.progress.lane.name),
251
+ )
252
+ const dependencies = new Map(
253
+ Object.entries(snapshot.documents.plans).map(([href, text]) => [
254
+ href,
255
+ readPhaseDependencies(text, context.language),
256
+ ]),
257
+ )
258
+ const document = readSprintDocument(snapshot.documents.sprint, context.language)
259
+ const refs = planRefs(snapshot, hrefsOf(document, views), context)
260
+ const planDocuments = documentsOf(snapshot, refs, context)
261
+ const anchors = new Map(planDocuments.map((doc) => [doc.repoPath, doc.anchor]))
262
+ return {
263
+ title: snapshot.title,
264
+ generatedAt: context.generatedAt,
265
+ baseRef: context.baseRef,
266
+ document,
267
+ plans: planTablesOf(snapshot, document, refs, context),
268
+ lanes: views.map((view) => laneOf(view, dependencies, refs)),
269
+ planDocuments,
270
+ planned: snapshot.planned.map(({ path, title, phaseIds, done, total }) => ({
271
+ title,
272
+ plan: { anchor: anchors.get(path) ?? null, file: fileLink(path, context) },
273
+ phaseIds,
274
+ done,
275
+ total,
276
+ })),
277
+ links: {
278
+ anchors,
279
+ blobBase: context.repoBlobBase,
280
+ root: context.root,
281
+ },
282
+ }
283
+ }
284
+
285
+ /** The file the page is written to: `<temporary directory>/jackherd/<board directory>.html`. */
286
+ export const sprintPagePath = (temporaryDirectory: string, sprintDirectory: string): string =>
287
+ join(temporaryDirectory, 'jackherd', `${posix.basename(sprintDirectory) || 'board'}.html`)